Fix legacy worktree lineage projection after stable updates (#9913)

This commit is contained in:
OrcaWin
2026-07-22 15:52:42 -07:00
committed by GitHub
parent 9bdb8204e1
commit 0d82cc3dbb
30 changed files with 2027 additions and 334 deletions
+121
View File
@@ -2306,6 +2306,127 @@ describe('registerWorktreeHandlers', () => {
})
})
it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => {
const parentPath = '/workspace/assigned-issues'
const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing'
const parentId = `repo-1::${parentPath}`
const childId = `repo-1::${childPath}`
const metaById: Record<string, { instanceId: string }> = {
[parentId]: { instanceId: 'parent-instance' },
[childId]: { instanceId: 'child-instance' }
}
store.getWorktreeMeta.mockImplementation((id: string) => metaById[id])
store.setWorktreeMeta.mockImplementation((id: string, updates: object) => ({
...metaById[id],
...updates
}))
store.getAllWorktreeLineage.mockReturnValue({
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
})
listWorktreesMock.mockResolvedValue([
{
path: childPath,
head: 'child-head',
branch: 'refs/heads/child',
isBare: false,
isMainWorktree: false
},
{
path: parentPath,
head: 'parent-head',
branch: 'refs/heads/parent',
isBare: false,
isMainWorktree: false
}
])
const result = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
expect(result.worktrees).toEqual([
expect.objectContaining({
id: childId,
parentWorktreeId: parentId,
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
}),
expect.objectContaining({
id: parentId,
parentWorktreeId: null,
childWorktreeIds: [childId],
lineage: null
})
])
})
it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => {
const folderRepo = {
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder' as const
}
const parentId = `${folderRepo.id}::${folderRepo.path}`
const childId = `${parentId}::workspace:child-instance`
const metaById: Record<string, Record<string, unknown>> = {
[parentId]: makeWorktreeMeta({
instanceId: 'parent-instance',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'repo-1'
}),
[childId]: makeWorktreeMeta({
instanceId: 'child-instance',
projectId: 'repo:repo-1',
hostId: 'local',
projectHostSetupId: 'repo-1'
})
}
store.getRepos.mockReturnValue([folderRepo])
store.getRepo.mockReturnValue(folderRepo)
store.getAllWorktreeMeta.mockReturnValue(metaById)
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
store.getAllWorktreeLineage.mockReturnValue({
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
})
const result = (await handlers['worktrees:listDetected'](null, {
repoId: folderRepo.id
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
expect(result.worktrees).toEqual([
expect.objectContaining({
id: parentId,
parentWorktreeId: null,
childWorktreeIds: [childId],
lineage: null
}),
expect.objectContaining({
id: childId,
parentWorktreeId: parentId,
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
})
])
})
it('hides agent scratch created inside a linked checkout from desktop listings', async () => {
const linkedCheckoutPath = '/workspace/feature-x'
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
+9 -3
View File
@@ -13,6 +13,7 @@ import {
} from '../../shared/workspace-scope'
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
import { deleteWorktreeHistoryDir } from '../terminal-history'
import type {
AutomationWorkspaceProvenance,
@@ -738,7 +739,7 @@ function buildDetectedGitWorktrees(
repo.path,
...liveWorktrees.map((worktree) => worktree.path)
])
return liveWorktrees.map((gitWorktree) => {
const detected = liveWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
let meta = store.getWorktreeMeta(worktreeId)
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
@@ -766,6 +767,7 @@ function buildDetectedGitWorktrees(
agentScratchWorktreePathMatcher
})
})
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
function stampAndMergeVisibleDetectedWorktree(
@@ -959,7 +961,7 @@ function buildDisconnectedDetectedWorktrees(
repo.path,
...worktrees.map((worktree) => worktree.path)
])
return worktrees.map((worktree) => {
const detected = worktrees.map((worktree) => {
const meta = store.getWorktreeMeta(worktree.id)
const detected = toDetectedWorktree({
repo,
@@ -972,6 +974,7 @@ function buildDisconnectedDetectedWorktrees(
})
return applyMetadataFallbackVisibility(detected)
})
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
export function registerWorktreeHandlers(
@@ -1149,7 +1152,10 @@ export function registerWorktreeHandlers(
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: buildFolderDetectedWorktrees(store, repo)
worktrees: projectResolvedWorktreeLineage(
buildFolderDetectedWorktrees(store, repo),
store.getAllWorktreeLineage?.() ?? {}
)
}
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
+212 -11
View File
@@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join, win32 } from 'node:path'
import { basename, join, win32 } from 'node:path'
import { ipcMain } from 'electron'
import type {
FolderWorkspace,
@@ -24854,19 +24854,34 @@ describe('OrcaRuntimeService', () => {
})
})
it('emits only instance-validated lineage parents in mobile summaries', async () => {
it('emits only instance- and boundary-validated lineage parents in mobile summaries', async () => {
// Regression: shipped mobile clients trust parentWorktreeId blindly, so worktree.ps must not emit stale same-path lineage.
const parentPath = '/tmp/worktree-parent'
const validChildPath = '/tmp/worktree-child-valid'
const staleChildPath = '/tmp/worktree-child-stale'
const parentPath = join(tmpdir(), 'worktree-parent')
const validChildPath = join(tmpdir(), 'worktree-child-valid')
const staleChildPath = join(tmpdir(), 'worktree-child-stale')
const crossHostChildPath = join(tmpdir(), 'worktree-child-cross-host')
const parentId = `${TEST_REPO_ID}::${parentPath}`
const validChildId = `${TEST_REPO_ID}::${validChildPath}`
const staleChildId = `${TEST_REPO_ID}::${staleChildPath}`
const crossHostChildId = `${TEST_REPO_ID}::${crossHostChildPath}`
const metaById: Record<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
[validChildId]: makeWorktreeMeta({ instanceId: 'child-instance' }),
[parentId]: makeWorktreeMeta({
instanceId: 'parent-instance',
hostId: 'local',
projectId: 'project-a'
}),
[validChildId]: makeWorktreeMeta({
instanceId: 'child-instance',
hostId: 'local',
projectId: 'project-a'
}),
// The stale child path was reused by a replacement checkout.
[staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' })
[staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }),
[crossHostChildId]: makeWorktreeMeta({
instanceId: 'cross-host-child-instance',
hostId: 'runtime:other-host',
projectId: 'project-a'
})
}
const makeLineage = (childId: string, worktreeInstanceId: string): WorktreeLineage => ({
worktreeId: childId,
@@ -24879,7 +24894,8 @@ describe('OrcaRuntimeService', () => {
})
const lineageById: Record<string, WorktreeLineage> = {
[validChildId]: makeLineage(validChildId, 'child-instance'),
[staleChildId]: makeLineage(staleChildId, 'old-child-instance')
[staleChildId]: makeLineage(staleChildId, 'old-child-instance'),
[crossHostChildId]: makeLineage(crossHostChildId, 'cross-host-child-instance')
}
const runtimeStore = {
...store,
@@ -24893,10 +24909,10 @@ describe('OrcaRuntimeService', () => {
getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId]
}
vi.mocked(listWorktrees).mockResolvedValue(
[parentPath, validChildPath, staleChildPath].map((path) => ({
[parentPath, validChildPath, staleChildPath, crossHostChildPath].map((path) => ({
path,
head: 'abc',
branch: `feature/${path.split('/').pop()}`,
branch: `feature/${basename(path)}`,
isBare: false,
isMainWorktree: false
}))
@@ -24918,6 +24934,13 @@ describe('OrcaRuntimeService', () => {
})
expect(staleSummary?.lineageWorktreeInstanceId).toBeUndefined()
expect(staleSummary?.parentWorktreeInstanceId).toBeUndefined()
const crossHostSummary = worktrees.find((worktree) => worktree.worktreeId === crossHostChildId)
expect(crossHostSummary).toMatchObject({
parentWorktreeId: null,
worktreeInstanceId: 'cross-host-child-instance'
})
expect(crossHostSummary?.lineageWorktreeInstanceId).toBeUndefined()
expect(crossHostSummary?.parentWorktreeInstanceId).toBeUndefined()
expect(worktrees.find((worktree) => worktree.worktreeId === parentId)).toMatchObject({
childWorktreeIds: [validChildId]
})
@@ -28051,6 +28074,80 @@ describe('OrcaRuntimeService', () => {
)
})
it.each([
{
boundary: 'repository',
childRepoId: 'repo-child',
parentRepoId: 'repo-parent',
childMeta: {},
parentMeta: {}
},
{
boundary: 'known host',
childRepoId: TEST_REPO_ID,
parentRepoId: TEST_REPO_ID,
childMeta: { hostId: 'runtime:child-host' as const },
parentMeta: { hostId: 'runtime:parent-host' as const }
},
{
boundary: 'known project',
childRepoId: TEST_REPO_ID,
parentRepoId: TEST_REPO_ID,
childMeta: { projectId: 'project-child' },
parentMeta: { projectId: 'project-parent' }
}
])('rejects manual lineage writes across a $boundary boundary', async (scenario) => {
const repos = [...new Set([scenario.childRepoId, scenario.parentRepoId])].map((id) => ({
id,
path: join(tmpdir(), id),
displayName: id,
badgeColor: 'blue' as const,
addedAt: 1
}))
const childRepoPath = repos.find((repo) => repo.id === scenario.childRepoId)!.path
const parentRepoPath = repos.find((repo) => repo.id === scenario.parentRepoId)!.path
const childPath = join(childRepoPath, 'child')
const parentPath = join(parentRepoPath, 'parent')
const childId = `${scenario.childRepoId}::${childPath}`
const parentId = `${scenario.parentRepoId}::${parentPath}`
const metaById: Record<string, WorktreeMeta> = {
[childId]: makeWorktreeMeta({ instanceId: 'child-instance', ...scenario.childMeta }),
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', ...scenario.parentMeta })
}
const setWorktreeLineage = vi.fn()
const setWorkspaceLineage = vi.fn()
const runtimeStore = {
...store,
getRepos: () => repos,
getRepo: (id: string) => repos.find((repo) => repo.id === id),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
return metaById[worktreeId]
},
getWorktreeLineage: () => undefined,
setWorktreeLineage,
setWorkspaceLineage
}
vi.mocked(listWorktrees).mockImplementation(async (repoPath) => [
...(repoPath === childRepoPath ? [makeWorktreeInfo(childPath)] : []),
...(repoPath === parentRepoPath ? [makeWorktreeInfo(parentPath)] : [])
])
const runtime = new OrcaRuntimeService(runtimeStore as never)
await expect(
runtime.updateManagedWorktreeMeta(`id:${childId}`, {
lineage: { parentWorktree: `id:${parentId}` }
})
).rejects.toThrow(
'Parent worktree must belong to the same repository, execution host, and project.'
)
expect(setWorktreeLineage).not.toHaveBeenCalled()
expect(setWorkspaceLineage).not.toHaveBeenCalled()
})
it('clears workspace lineage when manually removing a parent', async () => {
const childPath = '/tmp/worktree-child'
const childId = `${TEST_REPO_ID}::${childPath}`
@@ -28377,6 +28474,110 @@ describe('OrcaRuntimeService', () => {
expect(removeWorktreeLineage).not.toHaveBeenCalled()
})
it('hydrates runtime detected lists with instance-validated legacy lineage', async () => {
const parentPath = join(tmpdir(), 'worktree-parent')
const childPath = join(tmpdir(), 'worktree-child')
const parentId = `${TEST_REPO_ID}::${parentPath}`
const childId = `${TEST_REPO_ID}::${childPath}`
const metaById: Record<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
}
const lineageById: Record<string, WorktreeLineage> = {
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
}
const runtime = new OrcaRuntimeService({
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
getAllWorktreeLineage: () => lineageById
} as never)
vi.mocked(listWorktrees).mockResolvedValue([
makeWorktreeInfo(childPath),
makeWorktreeInfo(parentPath)
])
const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`)
expect(result.worktrees).toEqual([
expect.objectContaining({
id: childId,
parentWorktreeId: parentId,
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
}),
expect.objectContaining({
id: parentId,
parentWorktreeId: null,
childWorktreeIds: [childId],
lineage: null
})
])
})
it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => {
const folderRepo = {
id: 'folder-repo',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: 'blue' as const,
addedAt: 1,
kind: 'folder' as const
}
const parentId = `${folderRepo.id}::${folderRepo.path}`
const childId = `${parentId}::workspace:child-instance`
const metaById: Record<string, WorktreeMeta> = {
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
}
const lineageById: Record<string, WorktreeLineage> = {
[childId]: {
worktreeId: childId,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
}
const runtime = new OrcaRuntimeService({
...store,
getRepos: () => [folderRepo],
getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
return metaById[worktreeId]
},
getAllWorktreeLineage: () => lineageById
} as never)
const result = await runtime.listDetectedManagedWorktrees(`id:${folderRepo.id}`)
expect(result.worktrees).toEqual([
expect.objectContaining({
id: parentId,
parentWorktreeId: null,
childWorktreeIds: [childId],
lineage: null
}),
expect.objectContaining({
id: childId,
parentWorktreeId: parentId,
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
})
])
})
it('hides agent scratch created inside a linked checkout from runtime listings', async () => {
const linkedCheckoutPath = '/tmp/worktree-a'
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
+20 -40
View File
@@ -301,6 +301,10 @@ import {
parseWorkspaceKey,
worktreeWorkspaceKey
} from '../../shared/workspace-scope'
import {
projectResolvedWorktreeLineage,
sharesResolvedWorktreeLineageBoundary
} from '../../shared/resolved-worktree-lineage'
import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree'
import type {
FolderWorkspacePathStatus,
@@ -16555,13 +16559,15 @@ export class OrcaRuntimeService {
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
const repo = await this.resolveRepoSelector(repoSelector)
const store = this.requireStore()
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo)
const worktrees = listRuntimeFolderWorkspaces(store, repo)
const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
return {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
}
let scan: RuntimeWorktreeScanResult
@@ -16579,7 +16585,7 @@ export class OrcaRuntimeService {
])
const detected = scan.worktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = this.store?.getWorktreeMeta(worktreeId)
const meta = store.getWorktreeMeta(worktreeId)
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
const detectedWorktree = this.toRuntimeDetectedWorktree(
repo,
@@ -16595,7 +16601,7 @@ export class OrcaRuntimeService {
repoId: repo.id,
authoritative: scan.ok,
source: scan.ok ? 'git' : 'metadata-fallback',
worktrees: detected
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
}
@@ -23152,6 +23158,12 @@ export class OrcaRuntimeService {
if (childWorktreeId === parentWorktreeId) {
throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.')
}
if (!sharesResolvedWorktreeLineageBoundary(child, parent)) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_CONFLICT',
'Parent worktree must belong to the same repository, execution host, and project.'
)
}
const instanceByWorktreeId = new Map(
this.resolvedWorktreeCache?.worktrees.map((worktree) => [
worktree.id,
@@ -23722,7 +23734,10 @@ export class OrcaRuntimeService {
})
})
)
const worktrees = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat())
const worktrees = projectResolvedWorktreeLineage(
perRepoWorktrees.flat(),
this.store?.getAllWorktreeLineage?.() ?? {}
)
// Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca.
if (generation === this.resolvedWorktreeGeneration) {
this.resolvedWorktreeCache = {
@@ -23734,41 +23749,6 @@ export class OrcaRuntimeService {
return { worktrees, platformByRepoId }
}
private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] {
const lineageById = this.store?.getAllWorktreeLineage?.() ?? {}
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
const validLineageByChildId = new Map<string, WorktreeLineage>()
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)
if (
!child ||
!parent ||
child.instanceId !== lineage.worktreeInstanceId ||
parent.instanceId !== lineage.parentWorktreeInstanceId
) {
// Why: worktree IDs are path-derived, so instance checks keep replacement checkouts off stale same-path lineage.
continue
}
validLineageByChildId.set(childId, lineage)
const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? []
children.push(childId)
childIdsByParentId.set(lineage.parentWorktreeId, children)
}
return worktrees.map((worktree) => {
const lineage = validLineageByChildId.get(worktree.id) ?? null
return {
...worktree,
parentWorktreeId: lineage?.parentWorktreeId ?? null,
childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [],
lineage
}
})
}
private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void {
const store = this.store
if (
@@ -6,6 +6,7 @@ import type {
WorkspaceLineage
} from '../../../../shared/types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host'
import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees'
function makeFolder(id = 'folder-1'): FolderWorkspace {
@@ -166,4 +167,110 @@ describe('getAttachedWorktreesForFolderWorkspace', () => {
[nested.id]
)
})
it('includes an exact inline-only legacy descendant under an attached root', () => {
const parent = makeWorktree({
id: 'repo-1::/parent',
instanceId: 'parent'
})
const nested = makeWorktree({
id: 'repo-1::/nested',
instanceId: 'nested'
})
const inlineNested = {
...nested,
lineage: makeWorktreeLineage(nested, parent)
} as Worktree
const result = getAttachedWorktreesForFolderWorkspace({
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
activeWorktreeId: null,
folderWorkspaces: [makeFolder()],
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: {},
worktreesByRepo: { 'repo-1': [parent, inlineNested] }
})
expect(result.lineageChildrenByParentId.get(parent.id)?.map((worktree) => worktree.id)).toEqual(
[nested.id]
)
})
it('keeps a stale side-map entry authoritative over valid inline lineage', () => {
const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' })
const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' })
const inlineNested = {
...nested,
lineage: makeWorktreeLineage(nested, parent)
} as Worktree
const result = getAttachedWorktreesForFolderWorkspace({
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
activeWorktreeId: null,
folderWorkspaces: [makeFolder()],
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: {
[nested.id]: {
...makeWorktreeLineage(nested, parent),
parentWorktreeInstanceId: 'stale-parent'
}
},
worktreesByRepo: { 'repo-1': [parent, inlineNested] }
})
expect(result.lineageChildrenByParentId.size).toBe(0)
})
it('rejects nested descendants across host or project boundaries', () => {
const parent = makeWorktree({
id: 'repo-1::/parent',
instanceId: 'parent',
hostId: LOCAL_EXECUTION_HOST_ID,
projectId: 'project-1'
})
const hostChild = makeWorktree({
id: 'repo-1::/host-child',
instanceId: 'host-child',
hostId: toSshExecutionHostId('other')
})
const projectChild = makeWorktree({
id: 'repo-1::/project-child',
instanceId: 'project-child',
projectId: 'project-2'
})
const result = getAttachedWorktreesForFolderWorkspace({
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
activeWorktreeId: null,
folderWorkspaces: [makeFolder()],
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: {
[hostChild.id]: makeWorktreeLineage(hostChild, parent),
[projectChild.id]: makeWorktreeLineage(projectChild, parent)
},
worktreesByRepo: { 'repo-1': [parent, hostChild, projectChild] }
})
expect(result.lineageChildrenByParentId.size).toBe(0)
})
it('does not attach cyclic legacy descendants', () => {
const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' })
const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' })
const result = getAttachedWorktreesForFolderWorkspace({
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
activeWorktreeId: null,
folderWorkspaces: [makeFolder()],
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: {
[parent.id]: makeWorktreeLineage(parent, nested),
[nested.id]: makeWorktreeLineage(nested, parent)
},
worktreesByRepo: { 'repo-1': [parent, nested] }
})
expect(result.lineageChildrenByParentId.size).toBe(0)
expect(result.rootChildWorktrees.map((worktree) => worktree.id)).toEqual([parent.id])
})
})
@@ -6,6 +6,7 @@ import type {
WorkspaceLineage
} from '../../../../shared/types'
import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order'
import { getProjectedWorktreeLineageChildrenByParentId } from '../sidebar/worktree-lineage-projection'
export type AttachedWorktreeResolution = {
folderWorkspace: FolderWorkspace | null
@@ -90,39 +91,30 @@ export function getLineageChildrenByParentId(
worktreeById: Map<string, Worktree>,
rootWorktreeIds: ReadonlySet<string>
): Map<string, Worktree[]> {
const descendantsByParentId = new Map<string, Worktree[]>()
const projectedChildrenByParentId = getProjectedWorktreeLineageChildrenByParentId(
lineageById,
worktreeById
)
const includedIds = new Set(rootWorktreeIds)
let added = true
while (added) {
added = false
for (const lineage of Object.values(lineageById)) {
const parent = worktreeById.get(lineage.parentWorktreeId)
const child = worktreeById.get(lineage.worktreeId)
if (!isValidLineageChild(parent, child, lineage, includedIds)) {
const queue = [...rootWorktreeIds]
for (let index = 0; index < queue.length; index += 1) {
for (const child of projectedChildrenByParentId.get(queue[index]) ?? []) {
if (child.isArchived || includedIds.has(child.id)) {
continue
}
includedIds.add(child.id)
added = true
queue.push(child.id)
}
}
for (const worktreeId of includedIds) {
const child = worktreeById.get(worktreeId)
if (!child) {
continue
const descendantsByParentId = new Map<string, Worktree[]>()
for (const parentId of includedIds) {
const children = (projectedChildrenByParentId.get(parentId) ?? []).filter(
(child) => includedIds.has(child.id) && !child.isArchived
)
if (children.length > 0) {
descendantsByParentId.set(parentId, children)
}
const lineage = lineageById[child.id]
if (!lineage || !includedIds.has(lineage.parentWorktreeId)) {
continue
}
const parent = worktreeById.get(lineage.parentWorktreeId)
if (!isCurrentLineagePair(parent, child, lineage)) {
continue
}
const children = descendantsByParentId.get(parent.id) ?? []
children.push(child)
descendantsByParentId.set(parent.id, children)
}
for (const children of descendantsByParentId.values()) {
@@ -160,39 +152,6 @@ function getLineageChildWorktree(
return worktree
}
function isValidLineageChild(
parent: Worktree | undefined,
child: Worktree | undefined,
lineage: WorktreeLineage,
includedIds: ReadonlySet<string>
): child is Worktree {
if (
!parent ||
!child ||
parent.isArchived ||
child.isArchived ||
!includedIds.has(parent.id) ||
includedIds.has(child.id)
) {
return false
}
return isCurrentLineagePair(parent, child, lineage)
}
function isCurrentLineagePair(
parent: Worktree | undefined,
child: Worktree,
lineage: WorktreeLineage
): parent is Worktree {
return Boolean(
parent &&
!parent.isArchived &&
!child.isArchived &&
child.instanceId === lineage.worktreeInstanceId &&
parent.instanceId === lineage.parentWorktreeInstanceId
)
}
function sortWorktreesByRecentActivity(left: Worktree, right: Worktree): number {
return (
getWorktreeActivityTime(right) - getWorktreeActivityTime(left) ||
@@ -9,9 +9,11 @@ import {
shouldContinueDeleteSiblingPositionRestore,
getWorktreeParentPickerAnchor,
getWorktreeParentPickerLabel,
hasWorktreeParentLink,
isWorktreeParentPickerDisabled,
selectMenuScopedMap
} from './WorktreeContextMenu'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
describe('selectMenuScopedMap (delete-teardown re-render guard)', () => {
// Why: the closed menu wrapper must stay inert to delete teardown's high-churn
@@ -139,6 +141,26 @@ describe('shouldContinueDeleteSiblingPositionRestore', () => {
})
describe('parent picker context menu affordance', () => {
it('offers unlink for valid inline-only legacy lineage after stable-update hydration', () => {
const parent = { id: 'repo::parent', instanceId: 'parent-instance' }
const lineage: WorktreeLineage = {
worktreeId: 'repo::child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
const child = {
id: lineage.worktreeId,
instanceId: lineage.worktreeInstanceId,
lineage
} as Worktree & { lineage: WorktreeLineage }
expect(hasWorktreeParentLink(child, {}, {})).toBe(true)
})
it('uses set/change labels based on valid parent presence', () => {
expect(getWorktreeParentPickerLabel(null)).toBe('Set Parent Worktree...')
expect(getWorktreeParentPickerLabel('parent-1')).toBe('Change Parent Worktree...')
@@ -41,7 +41,11 @@ import { runSleepWorktrees } from './sleep-worktree-flow'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor'
import { getLineageRenderInfo } from './worktree-list-groups'
import {
getCyclicProjectedWorktreeLineageIds,
getLineageRenderInfo,
getProjectedWorktreeLineage
} from './worktree-lineage-projection'
import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status'
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
@@ -82,6 +86,7 @@ const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {}
const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {}
const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {}
const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {}
const EMPTY_CYCLIC_LINEAGE_IDS: ReadonlySet<string> = new Set()
// Why: the gating decision for the menu-only store subscriptions. When the menu is
// closed we MUST return the same `empty` reference every render so Zustand's Object.is
@@ -92,6 +97,17 @@ export function selectMenuScopedMap<T>(menuOpen: boolean, live: T, empty: T): T
return menuOpen ? live : empty
}
export function hasWorktreeParentLink(
worktree: Worktree,
lineageById: AppState['worktreeLineageById'],
workspaceLineageByChildKey: AppState['workspaceLineageByChildKey']
): boolean {
return Boolean(
getProjectedWorktreeLineage(worktree, lineageById) ||
workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)]
)
}
function shouldUseNativeContextMenu(target: EventTarget | null): boolean {
const maybeElement = target as {
closest?: (selector: string) => Element | null
@@ -376,29 +392,41 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
isMultiContext && batchDeleteWorktrees.length > 0
? `Delete ${batchDeleteWorktrees.length} Workspace${batchDeleteWorktrees.length === 1 ? '' : 's'}`
: 'Delete Selected'
const lineage = worktreeLineageById[worktree.id]
const workspaceLineage = workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)]
const hasParentLink = hasWorktreeParentLink(
worktree,
worktreeLineageById,
workspaceLineageByChildKey
)
const cyclicLineageIds = useMemo(
() =>
menuOpen
? getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap)
: EMPTY_CYCLIC_LINEAGE_IDS,
[menuOpen, worktreeLineageById, worktreeMap]
)
// Why: path-derived worktree IDs can be reused. The menu must honor the same
// instance check as grouped rows before offering navigation to a parent.
const lineageInfo = useMemo(
() => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap),
[worktree, worktreeLineageById, worktreeMap]
() => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap, cyclicLineageIds),
[cyclicLineageIds, worktree, worktreeLineageById, worktreeMap]
)
const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null
const hasAnyContextLineage = activeContextWorktrees.some(
(item) =>
worktreeLineageById[item.id] || workspaceLineageByChildKey[worktreeWorkspaceKey(item.id)]
const hasAnyContextLineage = activeContextWorktrees.some((item) =>
hasWorktreeParentLink(item, worktreeLineageById, workspaceLineageByChildKey)
)
const eligibleParentCount = useMemo(
() =>
getEligibleWorktreeParents({
child: worktree,
worktrees: allWorktrees,
lineageById: worktreeLineageById,
worktreeMap,
repoMap
}).length,
[allWorktrees, repoMap, worktree, worktreeLineageById, worktreeMap]
menuOpen
? getEligibleWorktreeParents({
child: worktree,
worktrees: allWorktrees,
lineageById: worktreeLineageById,
worktreeMap,
repoMap,
cyclicLineageIds
}).length
: 0,
[allWorktrees, cyclicLineageIds, menuOpen, repoMap, worktree, worktreeLineageById, worktreeMap]
)
const setMenuOpenState = useCallback(
@@ -814,7 +842,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
<FolderTree className="size-3.5" />
{getWorktreeParentPickerLabel(validParentWorktreeId)}
</DropdownMenuItem>
{(validParentWorktreeId || lineage || workspaceLineage) && (
{(validParentWorktreeId || hasParentLink) && (
<>
{validParentWorktreeId && (
<DropdownMenuItem onSelect={handleOpenParent} disabled={isDeleting}>
@@ -825,7 +853,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
)}
</DropdownMenuItem>
)}
{(lineage || workspaceLineage) && (
{hasParentLink && (
<DropdownMenuItem onSelect={handleRemoveParentLink} disabled={isDeleting}>
<Unlink className="size-3.5" />
{translate(
@@ -122,6 +122,10 @@ import {
setVisibleWorktreeIds,
sidebarHasActiveFilters
} from './visible-worktrees'
import {
getCyclicProjectedWorktreeLineageIds,
getWorktreeLineageAncestors
} from './worktree-lineage-projection'
import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state'
import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos'
import {
@@ -249,7 +253,7 @@ import {
suppressNewExternalWorktreeInbox,
type NewExternalWorktreesInboxActionState
} from './new-external-worktrees-inbox-actions'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import { isEligibleWorktreeParent } from './worktree-parent-candidates'
import {
buildImportedWorktreesCardCandidates,
getHiddenImportedWorktrees
@@ -1395,6 +1399,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent)
const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage)
const cyclicLineageIds = useMemo(
() => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap),
[worktreeLineageById, worktreeMap]
)
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(null)
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
const worktreePointerAutoscrollFrameIdRef = useRef<number | null>(null)
@@ -2085,25 +2093,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
toggleGroup(hostGroupKey)
}
const seen = new Set<string>()
let current: Worktree | undefined = targetWorktree
while (current && !seen.has(current.id)) {
seen.add(current.id)
const lineage = worktreeLineageById[current.id]
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
if (
!lineage ||
!parent ||
current.instanceId !== lineage.worktreeInstanceId ||
parent.instanceId !== lineage.parentWorktreeInstanceId
) {
break
}
for (const parent of getWorktreeLineageAncestors(
targetWorktree,
worktreeLineageById,
worktreeMap
)) {
const lineageGroupKey = getLineageGroupKey(parent.id)
if (collapsedGroups.has(lineageGroupKey)) {
toggleGroup(lineageGroupKey)
}
current = parent
}
const groupKeys =
@@ -2695,17 +2693,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (!child) {
return false
}
return getEligibleWorktreeParents({
child,
worktrees,
lineageById: worktreeLineageById,
worktreeMap,
repoMap
}).some((candidate) => candidate.id === parentId)
const candidateParent = worktreeMap.get(parentId)
return Boolean(
candidateParent &&
isEligibleWorktreeParent({
child,
candidateParent,
lineageById: worktreeLineageById,
worktreeMap,
repoMap,
cyclicLineageIds
})
)
})
return canAssignAll ? target : { ...target, lineageParentId: null }
},
[repoMap, worktreeLineageById, worktreeMap, worktrees]
[cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap]
)
const commitWorktreeLineageParentDrop = useCallback(
@@ -2742,7 +2745,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const ids = getReorderedWorktreeIdsToUnnest({
draggedIds: args.draggedIds,
sourceGroupIds: sourceGroup.worktreeIds,
lineageById: worktreeLineageById
lineageById: worktreeLineageById,
worktreeMap,
cyclicLineageIds
})
if (ids.length === 0) {
return
@@ -2760,7 +2765,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
)
},
[updateWorktreeLineage, worktreeDragGroups, worktreeLineageById]
[cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap]
)
const flushWorktreePointerDrag = useCallback(() => {
@@ -5496,16 +5501,9 @@ const WorktreeList = React.memo(function WorktreeList({
workspaceHostScope,
visibleWorkspaceHostIds,
defaultHostId: getSettingsFocusedExecutionHostId(settings),
worktreeLineageById
worktreeLineageById,
forcedVisibleWorktreeIds: agentSendTargetWorktreeId ? [agentSendTargetWorktreeId] : undefined
})
if (
agentSendTargetWorktreeId &&
!ids.includes(agentSendTargetWorktreeId) &&
worktreeMap.has(agentSendTargetWorktreeId)
) {
// Why: send-target mode is a temporary picker; surface the target card without rewriting the user's filters.
ids.push(agentSendTargetWorktreeId)
}
return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null)
}, [
agentSendTargetWorktreeId,
@@ -5573,22 +5571,12 @@ const WorktreeList = React.memo(function WorktreeList({
}
}
const seen = new Set<string>()
let current: Worktree | undefined = targetWorktree
while (current && !seen.has(current.id)) {
seen.add(current.id)
const lineage = worktreeLineageById[current.id]
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
if (
!lineage ||
!parent ||
current.instanceId !== lineage.worktreeInstanceId ||
parent.instanceId !== lineage.parentWorktreeInstanceId
) {
break
}
for (const parent of getWorktreeLineageAncestors(
targetWorktree,
worktreeLineageById,
worktreeMap
)) {
next.delete(getLineageGroupKey(parent.id))
current = parent
}
return next
}, [
@@ -171,6 +171,109 @@ describe('getFolderWorkspaceCardPrDisplay', () => {
expect(display).toMatchObject({ number: 4, status: 'success' })
})
it('includes a nested PR from exact inline-only legacy lineage', () => {
const parent = makeWorktree({ id: 'parent', instanceId: 'parent' })
const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 })
const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree
const display = getFolderWorkspaceCardPrDisplay({
folderWorkspaceId: 'folder-1',
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: {},
worktreeMap: new Map([
[parent.id, parent],
[inlineNested.id, inlineNested]
]),
repoMap: new Map([[repo.id, repo]]),
hostedReviewCache: null,
prCache: { 'repo-1::nested': makePrEntry(4, 'success') }
})
expect(display).toMatchObject({ number: 4, status: 'success' })
})
it('keeps a stale side-map entry authoritative over valid inline lineage', () => {
const parent = makeWorktree({ id: 'parent', instanceId: 'parent' })
const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 })
const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree
const display = getFolderWorkspaceCardPrDisplay({
folderWorkspaceId: 'folder-1',
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: {
[nested.id]: {
...makeWorktreeLineage(nested, parent),
parentWorktreeInstanceId: 'stale-parent'
}
},
worktreeMap: new Map([
[parent.id, parent],
[inlineNested.id, inlineNested]
]),
repoMap: new Map([[repo.id, repo]]),
hostedReviewCache: null,
prCache: { 'repo-1::nested': makePrEntry(4, 'success') }
})
expect(display).toBeNull()
})
it.each([
['repository', { repoId: 'repo-2' }, {}],
['known host', { hostId: 'ssh:remote' as const }, { hostId: 'local' as const }],
['known project', { projectId: 'project-b' }, { projectId: 'project-a' }]
])('excludes nested PRs across a %s boundary', (_boundary, childOverrides, parentOverrides) => {
const parent = makeWorktree({ id: 'parent', instanceId: 'parent', ...parentOverrides })
const nested = makeWorktree({
id: 'nested',
instanceId: 'nested',
linkedPR: 4,
...childOverrides
})
const nestedRepo = { ...repo, id: nested.repoId }
const display = getFolderWorkspaceCardPrDisplay({
folderWorkspaceId: 'folder-1',
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: { [nested.id]: makeWorktreeLineage(nested, parent) },
worktreeMap: new Map([
[parent.id, parent],
[nested.id, nested]
]),
repoMap: new Map([
[repo.id, repo],
[nestedRepo.id, nestedRepo]
]),
hostedReviewCache: null,
prCache: { [`${nested.repoId}::nested`]: makePrEntry(4, 'success') }
})
expect(display).toBeNull()
})
it('excludes nested PRs from cyclic projected lineage', () => {
const parent = makeWorktree({ id: 'parent', instanceId: 'parent' })
const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 })
const display = getFolderWorkspaceCardPrDisplay({
folderWorkspaceId: 'folder-1',
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
worktreeLineageById: {
[parent.id]: makeWorktreeLineage(parent, nested),
[nested.id]: makeWorktreeLineage(nested, parent)
},
worktreeMap: new Map([
[parent.id, parent],
[nested.id, nested]
]),
repoMap: new Map([[repo.id, repo]]),
hostedReviewCache: null,
prCache: { 'repo-1::nested': makePrEntry(4, 'success') }
})
expect(display).toBeNull()
})
it('uses branch-discovered PR cache for unlinked attached worktrees', () => {
const worktree = makeWorktree({ id: 'branch-discovered', linkedPR: null })
@@ -6,6 +6,7 @@ import {
type ParentPrChecksRow
} from '@/components/right-sidebar/parent-pr-checks-rows'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection'
type FolderWorkspaceCardPrDisplayArgs = {
folderWorkspaceId: string
@@ -77,21 +78,18 @@ function getAttachedWorktreesForFolderWorkspaceCard({
.filter((worktree): worktree is Worktree => worktree !== null)
const included = new Map(directChildren.map((worktree) => [worktree.id, worktree]))
let added = true
while (added) {
added = false
for (const lineage of Object.values(worktreeLineageById ?? {})) {
if (included.has(lineage.worktreeId) || !included.has(lineage.parentWorktreeId)) {
continue
}
const parent = worktreeMap.get(lineage.parentWorktreeId)
const child = worktreeMap.get(lineage.worktreeId)
if (!isCurrentLineagePair(parent, child, lineage)) {
const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId(
worktreeLineageById ?? {},
worktreeMap
)
const queue = [...directChildren]
for (let index = 0; index < queue.length; index += 1) {
for (const child of childrenByParentId.get(queue[index].id) ?? []) {
if (child.isArchived || included.has(child.id)) {
continue
}
included.set(child.id, child)
added = true
queue.push(child)
}
}
@@ -130,21 +128,6 @@ function getWorkspaceLineageChild(
return worktree
}
function isCurrentLineagePair(
parent: Worktree | undefined,
child: Worktree | undefined,
lineage: WorktreeLineage
): child is Worktree {
return Boolean(
parent &&
child &&
!parent.isArchived &&
!child.isArchived &&
child.instanceId === lineage.worktreeInstanceId &&
parent.instanceId === lineage.parentWorktreeInstanceId
)
}
function compareReviewDisplays(left: WorktreeCardPrDisplay, right: WorktreeCardPrDisplay): number {
return getReviewDisplayPriority(left) - getReviewDisplayPriority(right)
}
@@ -61,9 +61,10 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
workspaceHostScope,
visibleWorkspaceHostIds,
defaultHostId: getSettingsFocusedExecutionHostId(settings),
worktreeLineageById: {},
// Why: the board has no nested lineage presentation. Ancestor injection
// would make filtered-out parents appear as ordinary cards.
worktreeLineageById: {}
injectLineageAncestors: false
})
)
}, [
@@ -478,6 +478,109 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([parent.id, child.id])
})
it('includes a filtered parent from resolved inline lineage when hydration has no side-map entry', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const lineage = makeWorktreeLineage(child, parent)
const resolvedChild = { ...child, lineage }
const result = computeVisibleWorktreeIds(
{ repo1: [parent, resolvedChild] },
[child.id, parent.id],
visibleOptions({
showSleepingWorkspaces: false,
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
ptyIdsByTabId: { 't-child': ['p-child'] }
})
)
expect(result).toEqual([parent.id, child.id])
})
it('keeps inline parents out of non-nested board results across parent filters', () => {
const child = makeWorktree('child')
const run = (
parent: ReturnType<typeof makeWorktree>,
options: Partial<VisibleOptions>
): string[] => {
const resolvedChild = { ...child, lineage: makeWorktreeLineage(child, parent) }
return computeVisibleWorktreeIds(
{ repo1: [parent, resolvedChild] },
[parent.id, child.id],
visibleOptions({ ...options, injectLineageAncestors: false })
)
}
const sleepingParent = makeWorktree('sleeping-parent')
expect(
run(sleepingParent, {
showSleepingWorkspaces: false,
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
ptyIdsByTabId: { 't-child': ['p-child'] }
})
).toEqual([child.id])
const defaultBranchParent = makeWorktree('default-parent')
defaultBranchParent.isMainWorktree = true
expect(run(defaultBranchParent, { hideDefaultBranchWorkspace: true })).toEqual([child.id])
const automationParent = makeWorktree('automation-parent')
automationParent.automationProvenance = {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Review run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo1',
repoId: 'repo1',
hostId: 'local'
}
expect(run(automationParent, { hideAutomationGeneratedWorkspaces: true })).toEqual([child.id])
})
it('includes inline lineage ancestors when send-target mode forces a filtered child visible', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const lineage = makeWorktreeLineage(child, parent)
const resolvedChild = { ...child, lineage }
const result = computeVisibleWorktreeIds(
{ repo1: [parent, resolvedChild] },
[parent.id, child.id],
visibleOptions({
showSleepingWorkspaces: false,
forcedVisibleWorktreeIds: [child.id]
})
)
expect(result).toEqual([parent.id, child.id])
})
it('keeps the hydrated side-map authoritative over disagreeing inline lineage', () => {
const inlineParent = makeWorktree('inline-parent')
const hydratedParent = makeWorktree('hydrated-parent')
const child = makeWorktree('child')
const inlineLineage = makeWorktreeLineage(child, inlineParent)
const hydratedLineage = makeWorktreeLineage(child, hydratedParent)
const resolvedChild = { ...child, lineage: inlineLineage }
const result = computeVisibleWorktreeIds(
{ repo1: [inlineParent, hydratedParent, resolvedChild] },
[child.id, inlineParent.id, hydratedParent.id],
visibleOptions({
showSleepingWorkspaces: false,
tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] },
ptyIdsByTabId: { 't-child': ['p-child'] },
worktreeLineageById: { [child.id]: hydratedLineage }
})
)
expect(result).toEqual([hydratedParent.id, child.id])
})
it('does not resurrect stale lineage parents', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
@@ -537,7 +640,7 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([parent.id, child.id])
})
it('includes cross-repo parents when repo filtering leaves their valid child visible', () => {
it('does not include a cross-repo parent when repo filtering leaves the child visible', () => {
const parent = makeWorktree('parent', 'repo1')
const child = makeWorktree('child', 'repo2')
const lineage = makeWorktreeLineage(child, parent)
@@ -551,7 +654,44 @@ describe('computeVisibleWorktreeIds', () => {
})
)
expect(result).toEqual([parent.id, child.id])
expect(result).toEqual([child.id])
})
it('does not include a known cross-host parent after host filtering', () => {
const parent = Object.assign(makeWorktree('parent'), { hostId: 'ssh:remote' as const })
const child = Object.assign(makeWorktree('child'), { hostId: 'local' as const })
const lineage = makeWorktreeLineage(child, parent)
const result = computeVisibleWorktreeIds(
{ repo1: [parent, child] },
[child.id, parent.id],
visibleOptions({
visibleWorkspaceHostIds: ['local'],
worktreeLineageById: { [child.id]: lineage }
})
)
expect(result).toEqual([child.id])
})
it('does not include a known cross-project parent hidden by another filter', () => {
const parent = Object.assign(makeWorktree('parent'), {
projectId: 'project-b',
isMainWorktree: true
})
const child = Object.assign(makeWorktree('child'), { projectId: 'project-a' })
const lineage = makeWorktreeLineage(child, parent)
const result = computeVisibleWorktreeIds(
{ repo1: [parent, child] },
[child.id, parent.id],
visibleOptions({
hideDefaultBranchWorkspace: true,
worktreeLineageById: { [child.id]: lineage }
})
)
expect(result).toEqual([child.id])
})
})
@@ -11,6 +11,10 @@ import {
type ExecutionHostId,
type ExecutionHostScope
} from '../../../../shared/execution-host'
import {
getCyclicProjectedWorktreeLineageIds,
getLineageRenderInfo
} from './worktree-lineage-projection'
/**
* Whether a worktree represents the repo's default-branch row that the
@@ -124,6 +128,8 @@ export function computeVisibleWorktreeIds(
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
defaultHostId: ExecutionHostId
worktreeLineageById: Record<string, WorktreeLineage>
injectLineageAncestors?: boolean
forcedVisibleWorktreeIds?: readonly string[]
}
): string[] {
let all: Worktree[] = getAllWorktreesFromState({ worktreesByRepo })
@@ -177,6 +183,17 @@ export function computeVisibleWorktreeIds(
)
}
if (opts.forcedVisibleWorktreeIds && opts.forcedVisibleWorktreeIds.length > 0) {
const includedIds = new Set(all.map((worktree) => worktree.id))
for (const worktreeId of opts.forcedVisibleWorktreeIds) {
const worktree = lineageAncestorById.get(worktreeId)
if (worktree && !includedIds.has(worktreeId)) {
includedIds.add(worktreeId)
all.push(worktree)
}
}
}
// Apply cached sort order. Items not yet in the cache (e.g. brand-new
// worktrees before the next sortEpoch bump) are appended at the end.
const orderIndex = new Map(sortedIds.map((id, i) => [id, i]))
@@ -186,11 +203,10 @@ export function computeVisibleWorktreeIds(
return ai - bi
})
return addVisibleLineageAncestors(
all.map((w) => w.id),
lineageAncestorById,
opts.worktreeLineageById
)
const visibleIds = all.map((w) => w.id)
return opts.injectLineageAncestors === false
? visibleIds
: addVisibleLineageAncestors(visibleIds, lineageAncestorById, opts.worktreeLineageById)
}
function addVisibleLineageAncestors(
@@ -201,6 +217,7 @@ function addVisibleLineageAncestors(
const result: string[] = []
const included = new Set<string>()
const visiting = new Set<string>()
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeById)
const addWithAncestors = (id: string): void => {
if (included.has(id) || visiting.has(id)) {
@@ -211,16 +228,11 @@ function addVisibleLineageAncestors(
return
}
visiting.add(id)
const lineage = lineageById[id]
const parent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined
if (
parent &&
worktree.instanceId === lineage.worktreeInstanceId &&
parent.instanceId === lineage.parentWorktreeInstanceId
) {
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeById, cyclicLineageIds)
if (lineage.state === 'valid') {
// Why: sidebar lineage is structural. If a filtered child is visible,
// its valid parent must be rendered too so the hierarchy remains legible.
addWithAncestors(parent.id)
addWithAncestors(lineage.parent.id)
}
visiting.delete(id)
if (!included.has(id)) {
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
@@ -70,4 +71,68 @@ describe('getWorkspaceDeleteLineage', () => {
expect(lineage.descendants).toEqual([])
expect(lineage.deleteAllTargets).toEqual([parent])
})
it('orders an exact inline-only legacy descendant before its parent', () => {
const parent = makeWorktree('parent', '/workspaces/parent')
const child = makeWorktree('child', '/workspaces/parent/child')
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], {})
expect(lineage.descendants.map((worktree) => worktree.id)).toEqual([child.id])
expect(lineage.deleteAllTargets.map((worktree) => worktree.id)).toEqual([child.id, parent.id])
})
it('keeps a stale side-map child authoritative over valid inline lineage', () => {
const parent = makeWorktree('parent', '/workspaces/parent')
const child = makeWorktree('child', '/workspaces/parent/child')
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], {
[child.id]: {
...makeLineage(child, parent),
parentWorktreeInstanceId: 'stale-parent-instance'
}
})
expect(lineage.descendants).toEqual([])
expect(lineage.deleteAllTargets).toEqual([parent])
})
it('rejects cross-repo, cross-host, and cross-project descendants', () => {
const parent: Worktree = {
...makeWorktree('parent', '/workspaces/parent'),
hostId: LOCAL_EXECUTION_HOST_ID,
projectId: 'project-1'
}
const children: Worktree[] = [
{ ...makeWorktree('repo-child', '/workspaces/repo-child'), repoId: 'repo-2' },
{
...makeWorktree('host-child', '/workspaces/host-child'),
hostId: toSshExecutionHostId('other')
},
{ ...makeWorktree('project-child', '/workspaces/project-child'), projectId: 'project-2' }
]
const lineageById = Object.fromEntries(
children.map((child) => [child.id, makeLineage(child, parent)])
)
const lineage = getWorkspaceDeleteLineage(parent, [parent, ...children], lineageById)
expect(lineage.descendants).toEqual([])
expect(lineage.deleteAllTargets).toEqual([parent])
})
it('does not traverse cyclic projected lineage', () => {
const parent = makeWorktree('parent', '/workspaces/parent')
const child = makeWorktree('child', '/workspaces/parent/child')
const lineage = getWorkspaceDeleteLineage(parent, [parent, child], {
[parent.id]: makeLineage(parent, child),
[child.id]: makeLineage(child, parent)
})
expect(lineage.descendants).toEqual([])
expect(lineage.deleteAllTargets).toEqual([parent])
})
})
@@ -1,41 +1,21 @@
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection'
type WorkspaceDeleteLineage = {
descendants: Worktree[]
deleteAllTargets: Worktree[]
}
function isValidLineageLink(
child: Worktree,
parent: Worktree | undefined,
lineage: WorktreeLineage | undefined
): parent is Worktree {
return Boolean(
lineage &&
parent &&
child.instanceId === lineage.worktreeInstanceId &&
parent.instanceId === lineage.parentWorktreeInstanceId
)
}
export function getWorkspaceDeleteLineage(
parent: Worktree,
worktrees: readonly Worktree[],
lineageById: Record<string, WorktreeLineage>
): WorkspaceDeleteLineage {
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
const childrenByParentId = new Map<string, Worktree[]>()
for (const worktree of worktrees) {
const lineage = lineageById[worktree.id]
const lineageParent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined
if (!isValidLineageLink(worktree, lineageParent, lineage)) {
continue
}
const children = childrenByParentId.get(lineageParent.id) ?? []
children.push(worktree)
childrenByParentId.set(lineageParent.id, children)
}
const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId(
lineageById,
worktreeById
)
const descendants: Worktree[] = []
const childFirstTargets: Worktree[] = []
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection'
import {
getReorderedWorktreeIdsToUnnest,
getWorktreeLineageDropTargetId,
@@ -45,32 +47,121 @@ describe('getWorktreeLineageDropTargetId', () => {
describe('getReorderedWorktreeIdsToUnnest', () => {
it('clears parents only for directly dragged nested cards', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const root = makeWorktree('root')
const grandchild = makeWorktree('grandchild')
const lineageById = {
[child.id]: makeLineage(child, parent),
[grandchild.id]: makeLineage(grandchild, child)
}
const worktreeMap = new Map([parent, child, root, grandchild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: ['child', 'child', 'root', 'grandchild'],
sourceGroupIds: ['child', 'root', 'grandchild'],
lineageById: {
child: true,
grandchild: true
}
lineageById,
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
})
).toEqual(['child', 'grandchild'])
})
it('does not clear selected nested cards outside the reordered source group', () => {
const parent = makeWorktree('parent')
const sourceChild = makeWorktree('source-child')
const otherChild = makeWorktree('other-child')
const lineageById = {
[sourceChild.id]: makeLineage(sourceChild, parent),
[otherChild.id]: makeLineage(otherChild, parent)
}
const worktreeMap = new Map([parent, sourceChild, otherChild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: ['source-child', 'other-child'],
sourceGroupIds: ['source-child'],
lineageById: {
'source-child': true,
'other-child': true
}
lineageById,
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
})
).toEqual(['source-child'])
})
it('clears an exact inline-only legacy parent', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: [child.id],
sourceGroupIds: [child.id],
lineageById: {},
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds({}, worktreeMap)
})
).toEqual([child.id])
})
it('does not fall back to inline lineage when the side-map has a stale child entry', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
const lineageById = {
[child.id]: { ...makeLineage(child, parent), parentWorktreeInstanceId: 'stale-parent' }
}
const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: [child.id],
sourceGroupIds: [child.id],
lineageById,
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
})
).toEqual([])
})
})
function makeWorktree(id: string): Worktree {
return {
id,
instanceId: `${id}-instance`,
repoId: 'repo-1',
path: `/worktrees/${id}`,
head: 'abc123',
branch: id,
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1
}
}
function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage {
return {
worktreeId: child.id,
worktreeInstanceId: child.instanceId ?? '',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId ?? '',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1
}
}
function makeTarget(args: {
worktreeId: string
top: number
@@ -1,3 +1,6 @@
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import { getLineageRenderInfo } from './worktree-lineage-projection'
const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]'
const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]'
@@ -52,13 +55,22 @@ export function getWorktreeLineageDropTargetId(args: {
export function getReorderedWorktreeIdsToUnnest(args: {
draggedIds: readonly string[]
sourceGroupIds: readonly string[]
lineageById: Readonly<Record<string, unknown>>
lineageById: Readonly<Record<string, WorktreeLineage>>
worktreeMap: ReadonlyMap<string, Worktree>
cyclicLineageIds: ReadonlySet<string>
}): string[] {
const ids: string[] = []
const seen = new Set<string>()
const sourceGroupIdSet = new Set(args.sourceGroupIds)
for (const id of args.draggedIds) {
if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) {
const worktree = args.worktreeMap.get(id)
if (
seen.has(id) ||
!sourceGroupIdSet.has(id) ||
!worktree ||
getLineageRenderInfo(worktree, args.lineageById, args.worktreeMap, args.cyclicLineageIds)
.state !== 'valid'
) {
continue
}
seen.add(id)
@@ -0,0 +1,100 @@
import {
getCyclicWorktreeLineageChildIds,
isValidResolvedWorktreeLineageEdge
} from '../../../../shared/resolved-worktree-lineage'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
export type LineageRenderInfo =
| { state: 'none' }
| { state: 'valid'; lineage: WorktreeLineage; parent: Worktree }
| { state: 'missing'; lineage: WorktreeLineage }
type WorktreeWithResolvedLineage = Worktree & { lineage?: WorktreeLineage | null }
export function getProjectedWorktreeLineage(
worktree: Worktree,
lineageById: Readonly<Record<string, WorktreeLineage>>
): WorktreeLineage | null | undefined {
if (Object.prototype.hasOwnProperty.call(lineageById, worktree.id)) {
return lineageById[worktree.id]
}
return (worktree as WorktreeWithResolvedLineage).lineage
}
export function getCyclicProjectedWorktreeLineageIds(
lineageById: Readonly<Record<string, WorktreeLineage>>,
worktreeMap: ReadonlyMap<string, Worktree>
): Set<string> {
const validLineageByChildId = new Map<string, WorktreeLineage>()
for (const worktree of worktreeMap.values()) {
const lineage = getProjectedWorktreeLineage(worktree, lineageById)
if (!lineage) {
continue
}
const parent = worktreeMap.get(lineage.parentWorktreeId)
if (parent && isValidResolvedWorktreeLineageEdge(worktree, parent, lineage)) {
validLineageByChildId.set(worktree.id, lineage)
}
}
return getCyclicWorktreeLineageChildIds(validLineageByChildId)
}
export function getLineageRenderInfo(
worktree: Worktree,
lineageById: Readonly<Record<string, WorktreeLineage>>,
worktreeMap: ReadonlyMap<string, Worktree>,
cyclicLineageIds: ReadonlySet<string>
): LineageRenderInfo {
const lineage = getProjectedWorktreeLineage(worktree, lineageById)
if (!lineage) {
return { state: 'none' }
}
const parent = worktreeMap.get(lineage.parentWorktreeId)
if (
cyclicLineageIds.has(worktree.id) ||
!parent ||
!isValidResolvedWorktreeLineageEdge(worktree, parent, lineage)
) {
return { state: 'missing', lineage }
}
return { state: 'valid', lineage, parent }
}
export function getProjectedWorktreeLineageChildrenByParentId(
lineageById: Readonly<Record<string, WorktreeLineage>>,
worktreeMap: ReadonlyMap<string, Worktree>
): Map<string, Worktree[]> {
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
const childrenByParentId = new Map<string, Worktree[]>()
for (const worktree of worktreeMap.values()) {
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds)
if (lineage.state !== 'valid') {
continue
}
const children = childrenByParentId.get(lineage.parent.id) ?? []
children.push(worktree)
childrenByParentId.set(lineage.parent.id, children)
}
return childrenByParentId
}
export function getWorktreeLineageAncestors(
worktree: Worktree,
lineageById: Readonly<Record<string, WorktreeLineage>>,
worktreeMap: ReadonlyMap<string, Worktree>
): Worktree[] {
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
const ancestors: Worktree[] = []
const seen = new Set<string>()
let current: Worktree | undefined = worktree
while (current && !seen.has(current.id)) {
seen.add(current.id)
const lineage = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds)
if (lineage.state !== 'valid') {
break
}
ancestors.push(lineage.parent)
current = lineage.parent
}
return ancestors
}
@@ -19,6 +19,7 @@ import {
REPO_HEADER_ACTION_BUTTON_CLASS,
REPO_HEADER_ACTION_REVEAL_CLASS
} from './repo-header-action-button-class'
import { getWorktreeLineageAncestors } from './worktree-lineage-projection'
import type {
DetectedWorktree,
Project,
@@ -3309,6 +3310,12 @@ describe('project groups', () => {
})
describe('buildRows workspace lineage nesting', () => {
type ResolvedLineageWorktree = Worktree & {
lineage: WorktreeLineage | null
workspaceLineage?: null
parentWorktreeId?: string | null
}
const parent: Worktree = {
...worktree,
id: 'wt-parent',
@@ -3399,6 +3406,255 @@ describe('buildRows workspace lineage nesting', () => {
})
})
it('nests stable-update resolved legacy lineage when generalized lineage is absent', () => {
const parentId =
'32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/assigned-issues'
const childId =
'32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9276-nested-ssh-runtime-routing'
const secondChildId =
'32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9744-terminal-close-lifecycle'
const resolvedParent: ResolvedLineageWorktree = {
...parent,
id: parentId,
instanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c',
lineage: null,
workspaceLineage: null
}
const resolvedLineage: WorktreeLineage = {
...lineage,
worktreeId: childId,
worktreeInstanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b',
parentWorktreeId: parentId,
parentWorktreeInstanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' }
}
const resolvedChild: ResolvedLineageWorktree = {
...child,
id: childId,
instanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b',
lineage: resolvedLineage,
workspaceLineage: null
}
const secondResolvedLineage: WorktreeLineage = {
...resolvedLineage,
worktreeId: secondChildId,
worktreeInstanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417'
}
const secondResolvedChild: ResolvedLineageWorktree = {
...child,
id: secondChildId,
instanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417',
lineage: secondResolvedLineage,
workspaceLineage: null
}
const rows = buildRows(
'none',
[secondResolvedChild, resolvedChild, resolvedParent],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[resolvedParent.id, resolvedParent],
[resolvedChild.id, resolvedChild],
[secondResolvedChild.id, secondResolvedChild]
]),
true
)
const items = rows.filter((row) => row.type === 'item')
expect(items.map((row) => [row.worktree.id, row.depth])).toEqual([
[parentId, 0],
[secondChildId, 1],
[childId, 1]
])
expect(items[0]).toMatchObject({ lineageChildCount: 2, lineageCollapsed: false })
})
it('rejects stale resolved lineage after a parent instance is replaced', () => {
const resolvedChild: ResolvedLineageWorktree = {
...child,
lineage: { ...lineage, parentWorktreeInstanceId: 'replaced-parent-instance' }
}
const rows = buildRows(
'none',
[resolvedChild, parent],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([
[parent.id, parent],
[resolvedChild.id, resolvedChild]
]),
true
)
expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0])
})
it('keeps mixed cyclic lineage participants visible as roots', () => {
const parentLineage: WorktreeLineage = {
...lineage,
worktreeId: parent.id,
worktreeInstanceId: parent.instanceId!,
parentWorktreeId: child.id,
parentWorktreeInstanceId: child.instanceId!
}
const rows = buildRows(
'none',
[grandchild, child, parent],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{ [child.id]: lineage, [parent.id]: parentLineage },
new Map([
[parent.id, parent],
[child.id, child],
[grandchild.id, grandchild]
]),
true
)
expect(
rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth])
).toEqual([
[grandchild.id, 0],
[child.id, 0],
[parent.id, 0]
])
})
it('resolves inline-only ancestor chains for reveal and temporary picker expansion', () => {
const resolvedChild: ResolvedLineageWorktree = { ...child, lineage }
const resolvedGrandchild: ResolvedLineageWorktree = {
...grandchild,
lineage: grandchildLineage
}
const worktreeMap = new Map<string, Worktree>([
[parent.id, parent],
[resolvedChild.id, resolvedChild],
[resolvedGrandchild.id, resolvedGrandchild]
])
expect(
getWorktreeLineageAncestors(resolvedGrandchild, {}, worktreeMap).map(
(worktree) => worktree.id
)
).toEqual([child.id, parent.id])
})
it('keeps a resolved child at the root when its parent is missing', () => {
const resolvedChild: ResolvedLineageWorktree = { ...child, lineage }
const rows = buildRows(
'none',
[resolvedChild],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[child.id, resolvedChild]]),
true
)
expect(rows.find((row) => row.type === 'item')).toMatchObject({ depth: 0 })
})
it.each([
['repo', { repoId: 'other-repo' }],
['host', { hostId: 'ssh:other-host' as const }],
['project', { projectId: 'github:other/project' }]
])('does not nest resolved lineage across a known %s boundary', (_label, boundary) => {
const boundedParent = {
...parent,
repoId: 'repo-1',
hostId: 'local' as const,
projectId: 'github:stablyai/orca',
...boundary
}
const boundedChild: ResolvedLineageWorktree = {
...child,
repoId: 'repo-1',
hostId: 'local' as const,
projectId: 'github:stablyai/orca',
lineage
}
const rows = buildRows(
'none',
[boundedChild, boundedParent],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map<string, Worktree>([
[boundedParent.id, boundedParent],
[boundedChild.id, boundedChild]
]),
true
)
expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0])
})
it('keeps the hydrated lineage side-map authoritative when inline metadata disagrees', () => {
const otherParent = {
...parent,
id: 'wt-other-parent',
instanceId: 'other-parent-instance'
}
const hydratedLineage = {
...lineage,
parentWorktreeId: otherParent.id,
parentWorktreeInstanceId: otherParent.instanceId!
}
const resolvedChild: ResolvedLineageWorktree = {
...child,
parentWorktreeId: parent.id,
lineage
}
const rows = buildRows(
'none',
[resolvedChild, parent, otherParent],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{ [child.id]: hydratedLineage },
new Map([
[parent.id, parent],
[otherParent.id, otherParent],
[child.id, resolvedChild]
]),
true
)
expect(
rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth])
).toEqual([
[parent.id, 0],
[otherParent.id, 0],
[child.id, 1]
])
})
it('supports nested lineage chains beyond one level', () => {
const rows = buildRows(
'none',
@@ -3504,7 +3760,8 @@ describe('buildRows workspace lineage nesting', () => {
new Map([
[parent.id, parent],
[child.id, child]
])
]),
new Set()
)
expect(info).toMatchObject({ state: 'missing' })
@@ -43,6 +43,12 @@ import {
} from '../../../../shared/execution-host'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import {
getCyclicProjectedWorktreeLineageIds,
getLineageRenderInfo
} from './worktree-lineage-projection'
export { getLineageRenderInfo } from './worktree-lineage-projection'
export { branchName }
@@ -373,30 +379,6 @@ export function getLineageGroupKey(worktreeId: string): string {
return `${LINEAGE_GROUP_PREFIX}${worktreeId}`
}
export type LineageRenderInfo =
| { state: 'none' }
| { state: 'valid'; lineage: WorktreeLineage; parent: Worktree }
| { state: 'missing'; lineage: WorktreeLineage }
export function getLineageRenderInfo(
worktree: Worktree,
lineageById: Record<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>
): LineageRenderInfo {
const lineage = lineageById[worktree.id]
if (!lineage) {
return { state: 'none' }
}
const parent = worktreeMap.get(lineage.parentWorktreeId)
if (
!parent ||
worktree.instanceId !== lineage.worktreeInstanceId ||
parent.instanceId !== lineage.parentWorktreeInstanceId
) {
return { state: 'missing', lineage }
}
return { state: 'valid', lineage, parent }
}
export function getPRGroupKey(
worktree: Worktree,
repoMap: Map<string, Repo>,
@@ -603,9 +585,17 @@ function appendWorktreeRows(
groupDepth: number
sectionKey: string
hostContextLabelByRepoId?: ReadonlyMap<string, string>
cyclicLineageIds: ReadonlySet<string>
}
): void {
const { nestLineage, collapsedGroups, groupDepth, sectionKey, hostContextLabelByRepoId } = options
const {
nestLineage,
collapsedGroups,
groupDepth,
sectionKey,
hostContextLabelByRepoId,
cyclicLineageIds
} = options
if (!nestLineage) {
for (const worktree of worktrees) {
result.push(
@@ -629,7 +619,7 @@ function appendWorktreeRows(
const childrenByParentId = new Map<string, Worktree[]>()
const childIds = new Set<string>()
for (const worktree of worktrees) {
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap)
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds)
if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) {
continue
}
@@ -999,6 +989,9 @@ export function buildRows(
): Row[] {
const result: Row[] = []
const projectIndex = buildProjectGroupingIndex(projectGrouping)
const cyclicLineageIds = nestLineage
? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
: new Set<string>()
const pendingByRepo = new Map<string, PendingCreationRef[]>()
for (const creation of pendingCreations) {
@@ -1058,7 +1051,8 @@ export function buildRows(
nestLineage,
collapsedGroups,
groupDepth: 0,
sectionKey: ALL_GROUP_KEY
sectionKey: ALL_GROUP_KEY,
cyclicLineageIds
})
}
}
@@ -1302,7 +1296,8 @@ export function buildRows(
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
hostContextLabelByRepoId,
cyclicLineageIds
})
} else {
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
@@ -1310,7 +1305,8 @@ export function buildRows(
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId
hostContextLabelByRepoId,
cyclicLineageIds
})
}
}
@@ -1,6 +1,7 @@
import { getWorktreeExecutionHostId } from '../../../../shared/execution-host'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
import { canAssignWorktreeParent } from './worktree-parent-eligibility'
import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection'
type ParentCandidateArgs = {
child: Worktree
@@ -8,6 +9,7 @@ type ParentCandidateArgs = {
lineageById: Record<string, WorktreeLineage>
worktreeMap: Map<string, Worktree>
repoMap: Map<string, Pick<Repo, 'connectionId' | 'executionHostId'>>
cyclicLineageIds?: ReadonlySet<string>
}
function getWorktreeOwnerHostId(
@@ -23,20 +25,51 @@ export function getEligibleWorktreeParents({
worktrees,
lineageById,
worktreeMap,
repoMap
repoMap,
cyclicLineageIds: precomputedCyclicLineageIds
}: ParentCandidateArgs): Worktree[] {
const childHostId = getWorktreeOwnerHostId(child, repoMap)
return worktrees.filter(
(candidate) =>
candidate.repoId === child.repoId &&
childHostId !== null &&
getWorktreeOwnerHostId(candidate, repoMap) === childHostId &&
!candidate.isArchived &&
canAssignWorktreeParent({
child,
candidateParent: candidate,
lineageById,
worktreeMap
})
const cyclicLineageIds =
precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
return worktrees.filter((candidate) =>
isEligibleWorktreeParent({
child,
candidateParent: candidate,
lineageById,
worktreeMap,
repoMap,
cyclicLineageIds,
childHostId
})
)
}
export function isEligibleWorktreeParent({
child,
candidateParent,
lineageById,
worktreeMap,
repoMap,
cyclicLineageIds,
childHostId = getWorktreeOwnerHostId(child, repoMap)
}: Omit<ParentCandidateArgs, 'worktrees'> & {
candidateParent: Worktree
childHostId?: string | null
}): boolean {
return (
candidateParent.repoId === child.repoId &&
childHostId !== null &&
getWorktreeOwnerHostId(candidateParent, repoMap) === childHostId &&
(child.projectId === undefined ||
candidateParent.projectId === undefined ||
child.projectId === candidateParent.projectId) &&
!candidateParent.isArchived &&
canAssignWorktreeParent({
child,
candidateParent,
lineageById,
worktreeMap,
cyclicLineageIds
})
)
}
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
import { canAssignWorktreeParent } from './worktree-parent-eligibility'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import { getEligibleWorktreeParents, isEligibleWorktreeParent } from './worktree-parent-candidates'
function makeWorktree(id: string, repoId = 'repo'): Worktree {
return {
@@ -209,6 +209,34 @@ describe('canAssignWorktreeParent', () => {
).toEqual([sameHost.id])
})
it('excludes a candidate across a known project boundary for picker and direct drop checks', () => {
const child = { ...makeWorktree('child'), projectId: 'project-a' }
const sameProject = { ...makeWorktree('same-project'), projectId: 'project-a' }
const otherProject = { ...makeWorktree('other-project'), projectId: 'project-b' }
const worktrees = [child, sameProject, otherProject]
const worktreeMap = makeMap(worktrees)
const repoMap = makeRepoMap()
expect(
getEligibleWorktreeParents({
child,
worktrees,
lineageById: {},
worktreeMap,
repoMap
}).map((worktree) => worktree.id)
).toEqual([sameProject.id])
expect(
isEligibleWorktreeParent({
child,
candidateParent: otherProject,
lineageById: {},
worktreeMap,
repoMap
})
).toBe(false)
})
it('excludes archived worktrees from picker candidates', () => {
const child = makeWorktree('child')
const archived = makeWorktree('archived')
@@ -1,24 +1,31 @@
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import { getLineageRenderInfo } from './worktree-list-groups'
import {
getCyclicProjectedWorktreeLineageIds,
getLineageRenderInfo
} from './worktree-lineage-projection'
type ParentEligibilityArgs = {
child: Worktree
candidateParent: Worktree
lineageById: Record<string, WorktreeLineage>
worktreeMap: Map<string, Worktree>
cyclicLineageIds?: ReadonlySet<string>
}
export function canAssignWorktreeParent({
child,
candidateParent,
lineageById,
worktreeMap
worktreeMap,
cyclicLineageIds: precomputedCyclicLineageIds
}: ParentEligibilityArgs): boolean {
if (child.id === candidateParent.id) {
return false
}
const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap)
const cyclicLineageIds =
precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap, cyclicLineageIds)
if (childLineage.state === 'valid' && childLineage.parent.id === candidateParent.id) {
return false
}
@@ -26,14 +33,14 @@ export function canAssignWorktreeParent({
let current: Worktree | undefined = candidateParent
const visited = new Set<string>()
while (current) {
if (visited.has(current.id)) {
if (visited.has(current.id) || cyclicLineageIds.has(current.id)) {
return false
}
visited.add(current.id)
if (current.id === child.id) {
return false
}
const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap)
const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds)
// Why: stale instance links are broken edges for renderer filtering; the
// backend remains the authoritative final cycle guard.
current = lineageInfo.state === 'valid' ? lineageInfo.parent : undefined
@@ -1668,6 +1668,42 @@ describe('worktree lineage state', () => {
expect(store.getState().sortEpoch).toBe(4)
})
it('clears inline local lineage immediately when an inline-only child is unnested', async () => {
const store = createTestStore()
const lineage = makeLineage()
const parent = {
...makeWorktree({
id: lineage.parentWorktreeId,
instanceId: lineage.parentWorktreeInstanceId,
repoId: 'repo1'
}),
childWorktreeIds: [lineage.worktreeId],
lineage: null
}
const child = {
...makeWorktree({
id: lineage.worktreeId,
instanceId: lineage.worktreeInstanceId,
repoId: 'repo1'
}),
parentWorktreeId: lineage.parentWorktreeId,
childWorktreeIds: [],
lineage
}
mockApi.worktrees.updateLineage.mockResolvedValue(null)
store.setState({
worktreesByRepo: { repo1: [parent, child] },
worktreeLineageById: {}
} as Partial<AppState>)
await store.getState().updateWorktreeLineage(child.id, { noParent: true })
expect(store.getState().worktreesByRepo.repo1).toMatchObject([
{ id: parent.id, childWorktreeIds: [] },
{ id: child.id, parentWorktreeId: null, lineage: null }
])
})
it('syncs workspace lineage when a child is manually reparented', async () => {
const store = createTestStore()
const lineage = makeLineage({
@@ -5680,6 +5716,64 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
addedAt: 0
}
it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => {
const store = createTestStore()
const parent = makeWorktree({
id: 'repoA::/a/parent',
instanceId: 'parent-instance',
repoId: 'repoA',
path: '/a/parent'
})
const child = makeWorktree({
id: 'repoA::/a/child',
instanceId: 'child-instance',
repoId: 'repoA',
path: '/a/child'
})
const lineage = makeLineage({
worktreeId: child.id,
worktreeInstanceId: child.instanceId!,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId!
})
const resolvedParent = {
...parent,
parentWorktreeId: null,
childWorktreeIds: [child.id],
lineage: null,
workspaceLineage: null
}
const resolvedChild = {
...child,
parentWorktreeId: parent.id,
childWorktreeIds: [],
lineage,
workspaceLineage: null
}
mockApi.worktrees.listDetected.mockResolvedValueOnce(
makeDetectedResult('repoA', [resolvedParent, resolvedChild])
)
store.setState({
repos: [repoA],
hasHydratedWorktreePurge: true,
worktreeLineageById: {}
} as Partial<AppState>)
await store.getState().fetchAllWorktrees()
expect(store.getState().worktreeLineageById).toEqual({})
expect(store.getState().worktreesByRepo.repoA).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: child.id,
parentWorktreeId: parent.id,
lineage,
workspaceLineage: null
})
])
)
})
it('defers the purge when a sibling repo fetch fails (F1 regression)', async () => {
const store = createTestStore()
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
+58 -10
View File
@@ -1062,6 +1062,51 @@ async function setWorktreeLineageForRuntime(
}
}
function projectLocalWorktreeLineageUpdate(
worktreesByRepo: Record<string, Worktree[]>,
worktreeId: string,
lineage: WorktreeLineage | null
): Record<string, Worktree[]> {
let nextByRepo = worktreesByRepo
for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) {
let repoChanged = false
const projected = worktrees.map((worktree) => {
const current = worktree as WorktreeWithLineage
const hadChild = current.childWorktreeIds?.includes(worktreeId) ?? false
const isParent =
lineage?.parentWorktreeId === worktree.id &&
lineage.parentWorktreeInstanceId === worktree.instanceId
let childWorktreeIds = current.childWorktreeIds
if (hadChild) {
childWorktreeIds = childWorktreeIds?.filter((id) => id !== worktreeId)
}
if (isParent && !childWorktreeIds?.includes(worktreeId)) {
childWorktreeIds = [...(childWorktreeIds ?? []), worktreeId]
}
if (worktree.id === worktreeId) {
repoChanged = true
return {
...worktree,
parentWorktreeId: lineage?.parentWorktreeId ?? null,
lineage
}
}
if (hadChild || isParent) {
repoChanged = true
return { ...worktree, childWorktreeIds }
}
return worktree
})
if (repoChanged) {
if (nextByRepo === worktreesByRepo) {
nextByRepo = { ...worktreesByRepo }
}
nextByRepo[repoId] = projected
}
}
return nextByRepo
}
function applyWorktreeLineageUpdate(
set: Parameters<StateCreator<AppState>>[0],
worktreeId: string,
@@ -1074,6 +1119,18 @@ function applyWorktreeLineageUpdate(
} else {
delete next[worktreeId]
}
const worktreesByRepo =
result.target.kind === 'local'
? projectLocalWorktreeLineageUpdate(s.worktreesByRepo, worktreeId, result.lineage)
: result.updatedRemoteWorktree
? replaceWorktreeInRepoLists(
s.worktreesByRepo,
withRepoHostOwnership(
result.updatedRemoteWorktree,
repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id))
)
)
: s.worktreesByRepo
return {
worktreeLineageById: next,
workspaceLineageByChildKey: projectWorktreeLineageToWorkspaceLineage(
@@ -1081,16 +1138,7 @@ function applyWorktreeLineageUpdate(
result.lineage,
s.workspaceLineageByChildKey
),
worktreesByRepo:
result.target.kind === 'local' || !result.updatedRemoteWorktree
? s.worktreesByRepo
: replaceWorktreeInRepoLists(
s.worktreesByRepo,
withRepoHostOwnership(
result.updatedRemoteWorktree,
repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id))
)
),
worktreesByRepo,
sortEpoch: s.sortEpoch + 1
}
})
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import type { Worktree, WorktreeLineage } from './types'
import { projectResolvedWorktreeLineage } from './resolved-worktree-lineage'
function worktree(id: string, instanceId: string, overrides: Partial<Worktree> = {}): Worktree {
return {
id,
instanceId,
repoId: 'repo',
path: join('workspace', id),
head: 'abc123',
branch: `refs/heads/${id}`,
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function lineage(overrides: Partial<WorktreeLineage> = {}): WorktreeLineage {
return {
worktreeId: 'child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'parent',
parentWorktreeInstanceId: 'parent-instance',
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1,
...overrides
}
}
describe('projectResolvedWorktreeLineage', () => {
const parent = worktree('parent', 'parent-instance')
const child = worktree('child', 'child-instance')
it('projects exact instance-aware parent and child metadata', () => {
const projected = projectResolvedWorktreeLineage([child, parent], { child: lineage() })
expect(projected).toMatchObject([
{ id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [], lineage: lineage() },
{ id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'], lineage: null }
])
})
it.each([
['stale child instance', lineage({ worktreeInstanceId: 'old-child' })],
['stale parent instance', lineage({ parentWorktreeInstanceId: 'old-parent' })],
['mismatched child record', lineage({ worktreeId: 'other-child' })]
])('rejects %s', (_label, candidate) => {
const projected = projectResolvedWorktreeLineage([child, parent], { child: candidate })
expect(projected).toMatchObject([
{ id: 'child', parentWorktreeId: null, lineage: null },
{ id: 'parent', childWorktreeIds: [] }
])
})
it.each([
['repo', { repoId: 'other-repo' }, {}],
['known host', { hostId: 'local' as const }, { hostId: 'ssh:remote' as const }],
['known project', { projectId: 'github:stablyai/orca' }, { projectId: 'github:other/project' }]
])('rejects a %s boundary mismatch', (_label, childOverrides, parentOverrides) => {
const boundedChild = worktree('child', 'child-instance', childOverrides)
const boundedParent = worktree('parent', 'parent-instance', parentOverrides)
const projected = projectResolvedWorktreeLineage([boundedChild, boundedParent], {
child: lineage()
})
expect(projected).toMatchObject([
{ id: 'child', parentWorktreeId: null, lineage: null },
{ id: 'parent', childWorktreeIds: [] }
])
})
it('accepts legacy records when only one side has host or project identity', () => {
const legacyChild = worktree('child', 'child-instance', {
hostId: 'local',
projectId: 'github:stablyai/orca'
})
const projected = projectResolvedWorktreeLineage([legacyChild, parent], {
child: lineage()
})
expect(projected).toMatchObject([
{ id: 'child', parentWorktreeId: 'parent', lineage: lineage() },
{ id: 'parent', childWorktreeIds: ['child'] }
])
})
it('rejects self-parent lineage', () => {
const projected = projectResolvedWorktreeLineage([child], {
child: lineage({
parentWorktreeId: child.id,
parentWorktreeInstanceId: child.instanceId!
})
})
expect(projected[0]).toMatchObject({
parentWorktreeId: null,
childWorktreeIds: [],
lineage: null
})
})
it('rejects every edge in a multi-node cycle without hiding valid descendants', () => {
const grandchild = worktree('grandchild', 'grandchild-instance')
const parentToChild = lineage({
worktreeId: parent.id,
worktreeInstanceId: parent.instanceId!,
parentWorktreeId: child.id,
parentWorktreeInstanceId: child.instanceId!
})
const grandchildToParent = lineage({
worktreeId: grandchild.id,
worktreeInstanceId: grandchild.instanceId!
})
const projected = projectResolvedWorktreeLineage([child, parent, grandchild], {
child: lineage(),
parent: parentToChild,
grandchild: grandchildToParent
})
expect(projected).toMatchObject([
{ id: 'child', parentWorktreeId: null, childWorktreeIds: [], lineage: null },
{
id: 'parent',
parentWorktreeId: null,
childWorktreeIds: ['grandchild'],
lineage: null
},
{ id: 'grandchild', parentWorktreeId: 'parent', lineage: grandchildToParent }
])
})
it('rejects a missing parent without mutating the raw lineage record', () => {
const rawLineage = lineage()
const projected = projectResolvedWorktreeLineage([child], { child: rawLineage })
expect(projected[0]).toMatchObject({ parentWorktreeId: null, lineage: null })
expect(rawLineage.parentWorktreeId).toBe('parent')
})
it('replaces disagreeing parent and child projections from the validated lineage record', () => {
const projected = projectResolvedWorktreeLineage(
[
{ ...child, parentWorktreeId: 'stale-parent', childWorktreeIds: ['stale-child'] },
{ ...parent, parentWorktreeId: 'stale-parent', childWorktreeIds: [] }
] as (Worktree & { parentWorktreeId: string; childWorktreeIds: string[] })[],
{ child: lineage() }
)
expect(projected).toMatchObject([
{ id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [] },
{ id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'] }
])
})
})
+108
View File
@@ -0,0 +1,108 @@
import type { Worktree, WorktreeLineage } from './types'
export type WorktreeWithResolvedLineage<T extends Worktree = Worktree> = T & {
parentWorktreeId: string | null
childWorktreeIds: string[]
lineage: WorktreeLineage | null
}
export function sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: Worktree): boolean {
return (
child.repoId === parent.repoId &&
(child.hostId === undefined || parent.hostId === undefined || child.hostId === parent.hostId) &&
(child.projectId === undefined ||
parent.projectId === undefined ||
child.projectId === parent.projectId)
)
}
export function isValidResolvedWorktreeLineageEdge(
child: Worktree,
parent: Worktree,
lineage: WorktreeLineage
): boolean {
return (
child.id !== parent.id &&
lineage.worktreeId === child.id &&
lineage.parentWorktreeId === parent.id &&
sharesResolvedWorktreeLineageBoundary(child, parent) &&
child.instanceId === lineage.worktreeInstanceId &&
parent.instanceId === lineage.parentWorktreeInstanceId
)
}
export function getCyclicWorktreeLineageChildIds(
lineageByChildId: ReadonlyMap<string, WorktreeLineage>
): Set<string> {
const processed = new Set<string>()
const cyclic = new Set<string>()
for (const childId of lineageByChildId.keys()) {
if (processed.has(childId)) {
continue
}
const path: string[] = []
const pathIndexById = new Map<string, number>()
let currentId: string | undefined = childId
while (currentId && lineageByChildId.has(currentId) && !processed.has(currentId)) {
const cycleStart = pathIndexById.get(currentId)
if (cycleStart !== undefined) {
for (let index = cycleStart; index < path.length; index += 1) {
cyclic.add(path[index])
}
break
}
pathIndexById.set(currentId, path.length)
path.push(currentId)
currentId = lineageByChildId.get(currentId)?.parentWorktreeId
}
for (const id of path) {
processed.add(id)
}
}
return cyclic
}
export function projectResolvedWorktreeLineage<T extends Worktree>(
worktrees: readonly T[],
lineageById: Readonly<Record<string, WorktreeLineage>>
): WorktreeWithResolvedLineage<T>[] {
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
const validLineageByChildId = new Map<string, WorktreeLineage>()
const childIdsByParentId = new Map<string, string[]>()
for (const child of worktrees) {
const childId = child.id
const lineage = lineageById[childId]
if (!lineage) {
continue
}
const parent = worktreeById.get(lineage.parentWorktreeId)
if (!parent || !isValidResolvedWorktreeLineageEdge(child, parent, lineage)) {
continue
}
validLineageByChildId.set(childId, lineage)
}
const cyclicChildIds = getCyclicWorktreeLineageChildIds(validLineageByChildId)
for (const childId of cyclicChildIds) {
validLineageByChildId.delete(childId)
}
for (const [childId, lineage] of validLineageByChildId) {
const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? []
children.push(childId)
childIdsByParentId.set(lineage.parentWorktreeId, children)
}
return worktrees.map((worktree) => {
const lineage = validLineageByChildId.get(worktree.id) ?? null
return {
...worktree,
parentWorktreeId: lineage?.parentWorktreeId ?? null,
childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [],
lineage
}
})
}
+32 -17
View File
@@ -5,8 +5,11 @@ export type LineageScenario = {
childId: string
}
export async function seedLineageScenario(page: Page): Promise<LineageScenario> {
return page.evaluate(() => {
export async function seedLineageScenario(
page: Page,
options: { inlineOnly?: boolean } = {}
): Promise<LineageScenario> {
return page.evaluate(({ inlineOnly }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
@@ -35,38 +38,50 @@ export async function seedLineageScenario(page: Page): Promise<LineageScenario>
if (!parent.instanceId || !child.instanceId) {
throw new Error('Worktree lineage E2E needs instance-stamped worktrees')
}
const lineage = {
worktreeId: child.id,
worktreeInstanceId: child.instanceId,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual' as const,
capture: { source: 'manual-action' as const, confidence: 'explicit' as const },
createdAt: Date.now()
}
store.setState((current) => ({
worktreesByRepo: Object.fromEntries(
Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [
repoId,
repoWorktrees.map((worktree) => {
if (worktree.id === parent.id) {
return { ...worktree, displayName: 'E2E lineage parent', sortOrder: 0 }
return {
...worktree,
displayName: 'E2E lineage parent',
sortOrder: 0,
...(inlineOnly
? { parentWorktreeId: null, childWorktreeIds: [child.id], lineage: null }
: {})
}
}
if (worktree.id === child.id) {
return { ...worktree, displayName: 'E2E lineage child', sortOrder: 1 }
return {
...worktree,
displayName: 'E2E lineage child',
sortOrder: 1,
...(inlineOnly
? { parentWorktreeId: parent.id, childWorktreeIds: [], lineage }
: {})
}
}
return worktree
})
])
),
worktreeLineageById: {
...current.worktreeLineageById,
[child.id]: {
worktreeId: child.id,
worktreeInstanceId: child.instanceId,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: Date.now()
}
}
worktreeLineageById: inlineOnly ? {} : { ...current.worktreeLineageById, [child.id]: lineage }
}))
store.getState().setActiveWorktree(parent.id)
return { parentId: parent.id, childId: child.id }
})
}, options)
}
export async function seedWorkspaceAgentStatus(
+37
View File
@@ -1,4 +1,6 @@
import type { Page } from '@stablyai/playwright-test'
import { mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
@@ -13,6 +15,20 @@ function worktreeOption(page: Page, worktreeId: string) {
return worktreeRow(page, worktreeId)
}
async function captureSidebarEvidence(page: Page, name: string): Promise<void> {
if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') {
return
}
const outputDir = resolve(process.cwd(), 'pr-evidence')
mkdirSync(outputDir, { recursive: true })
await page
.locator('[data-worktree-sidebar]')
.first()
.screenshot({
path: resolve(outputDir, name)
})
}
test.describe('Worktree Lineage', () => {
test.describe.configure({ mode: 'serial' })
@@ -87,6 +103,27 @@ test.describe('Worktree Lineage', () => {
await expect(childRow).toBeVisible()
})
test('renders legacy-only inline lineage when side-map hydration is absent', async ({
orcaPage
}) => {
const { parentId, childId } = await seedLineageScenario(orcaPage, { inlineOnly: true })
const parentRow = worktreeOption(orcaPage, parentId)
const childRow = worktreeOption(orcaPage, childId)
await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible()
await expect(childRow).toBeVisible()
await expect
.poll(async () => {
const [parentBox, childBox] = await Promise.all([
parentRow.boundingBox(),
childRow.boundingBox()
])
return parentBox && childBox ? childBox.y > parentBox.y : false
})
.toBe(true)
await captureSidebarEvidence(orcaPage, 'legacy-inline-lineage-nested.png')
})
test('injects filtered parents structurally without showing a parent badge', async ({
orcaPage
}) => {