mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Fix legacy worktree lineage projection after stable updates (#9913)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+107
@@ -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' })
|
||||
|
||||
@@ -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'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user