mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* feat: select parent worktree for nesting when creating workspace Enable users to pick a parent workspace in the composer's Advanced drawer, organizing newly created worktrees hierarchically in the sidebar. The backend validates lineage relationships and gracefully retries without the parent if it becomes unavailable during creation. * feat: select parent worktree for nesting when creating workspace - Record app-picked parents as manual actions, not CLI-flag equivalents, ensuring the same user action carries consistent cleanup semantics across hosts - Gracefully retry without parent if the selection goes stale, warn the user instead of failing - Refactor create into modules: parent resolution, payload building, state merge * Allow selecting parent worktree when creating workspace - New parent worktree picker filtered by execution host and project - Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership * Allow selecting parent worktree when creating workspace - Rename "Parent workspace" label to "Parent worktree" - Filter candidates by execution host and project to prevent nesting across hosts - Wire parentWorktreeId through composer state and creation request pipeline - Update translations and add new copy for nesting-related messages
121 lines
3.9 KiB
TypeScript
121 lines
3.9 KiB
TypeScript
import type { WorktreeLineage } from './worktree/lineage-types'
|
|
import type { Worktree } from './worktree/types'
|
|
|
|
export type WorktreeWithResolvedLineage<T extends Worktree = Worktree> = T & {
|
|
parentWorktreeId: string | null
|
|
childWorktreeIds: string[]
|
|
lineage: WorktreeLineage | null
|
|
}
|
|
|
|
/** The fields a lineage edge is scoped by. Split out so create-time callers — which have a
|
|
* projected child, not a real Worktree — can check the same rule the projection enforces. */
|
|
export type WorktreeLineageBoundary = Pick<Worktree, 'repoId' | 'hostId' | 'projectId'>
|
|
|
|
export function sharesWorktreeLineageBoundary(
|
|
child: WorktreeLineageBoundary,
|
|
parent: WorktreeLineageBoundary
|
|
): 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 sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: Worktree): boolean {
|
|
return sharesWorktreeLineageBoundary(child, parent)
|
|
}
|
|
|
|
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
|
|
}
|
|
})
|
|
}
|