Files
orca/src/main/runtime/runtime-managed-worktree-metadata.ts
T
Neil c4a60815b2 fix(worktrees): a sort order and a minted push remote land on the copy that named them (#30976)
* fix(worktrees): a sort order and a minted push remote land on the copy that named them, not skipped for a twin

The renderer already knows each id's host when it splits a sort order; it now sends it
(an optional parallel hostIds; older peers ignore it and keep the skip-twins rule). An IPC
git handler knows the host it required, so a minted push remote records on that copy.

* fix(worktrees): a twinned repo re-lists instead of an id-keyed sort patch; client hosts must name a registered repo

* fix(worktrees): a sort order naming an unregistered host skips that id instead of falling back

* fix(worktrees): a moving project's sort order is fenced before any host-qualified read

* fix(worktrees): a client's local sort-order host matches this server's own runtime stamp

* fix(worktrees): an exact host match wins before the own-runtime alias

* fix(worktrees): terminal-spawn materialization serves only a local row and records on the local copy

* test: drop casts from the spawn twin tests

* fix(worktrees): adopt evidence and the spawn hook stay on their host; an unmatched local keeps the id-only rule

* fix(worktrees): adopt evidence reads the host's own metadata rows

* fix(worktrees): reusing a same-URL remote reads ownership from this host only

* fix(worktrees): a server's own runtime-stamped row still materializes its push remote
2026-10-19 20:20:23 -07:00

193 lines
7.2 KiB
TypeScript

import type { GitPushTarget, Worktree } from '../../shared/worktree/types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { splitWorktreeId } from '../../shared/worktree/id'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { WorktreeSortOrderUpdate } from '../../shared/worktree/sort-order-update'
import { stripOrcaProvenanceMetaUpdates } from '../worktree-removal-safety'
import { isRemovedFolderWorkspaceInstance } from '../folder-workspace-meta-presence'
import { worktreeRowTarget } from '../persistence/host-qualified-worktree-meta'
import {
writeWorktreeSortOrder,
type WorktreeSortOrderRequest
} from '../persistence/worktree-sort-order-write'
import type { RuntimeStore } from './runtime-store-contract'
import {
isWslMoveSourceHost,
isWslMoveWorkspaceBlocked,
shouldDropWslMoveProjectUpdate,
wslMoveProjectRefForWorkspace
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
import { RuntimeLineageError } from './runtime-worktree-lineage-resolution'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
type Updates = Omit<Partial<WorktreeMeta>, 'pushTarget'> & {
pushTarget?: GitPushTarget | null
lineage?: { parentWorktree?: string; noParent?: boolean }
}
type Ports = {
resolveWorktree: (selector: string) => Promise<ResolvedWorktree>
validateParent: (worktree: ResolvedWorktree, parent: ResolvedWorktree) => void
invalidateResolved: () => void
invalidateScan: (repoId: string) => void
notifyChanged: (repoId: string) => void
showWorktree: (selector: string) => Promise<Worktree>
}
export async function updateRuntimeManagedWorktreeMetadata(args: {
selector: string
updates: Updates
store: RuntimeStore
ports: Ports
}): Promise<Worktree> {
const worktree = await args.ports.resolveWorktree(args.selector)
const { lineage, ...metaUpdates } = args.updates
const previousParentRepoId = lineage
? splitWorktreeId(args.store.getWorktreeLineage?.(worktree.id)?.parentWorktreeId ?? '')?.repoId
: undefined
let nextParentRepoId: string | undefined
const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId
if (
isWslMoveSourceHost(executionHostId) &&
shouldDropWslMoveProjectUpdate(
args.store,
wslMoveProjectRefForWorkspace(worktree.id),
lineage ? args.updates : metaUpdates
)
) {
return worktree
}
const target = worktreeRowTarget(args.store, { id: worktree.id, hostId: executionHostId })
if (!target) {
throw new Error('selector_ambiguous')
}
if (lineage?.parentWorktree) {
args.ports.invalidateResolved()
args.ports.invalidateScan(worktree.repoId)
}
const clearPushTarget =
Object.hasOwn(metaUpdates, 'pushTarget') && metaUpdates.pushTarget === null
const normalized: Partial<WorktreeMeta> = clearPushTarget
? { ...metaUpdates, pushTarget: undefined }
: (metaUpdates as Partial<WorktreeMeta>)
const persisted: Partial<WorktreeMeta> = omitUndefinedProperties(
normalized.displayName !== undefined
? {
...normalized,
pendingFirstAgentMessageRename: false,
firstAgentMessageRenameError: null
}
: normalized
)
if (clearPushTarget) {
persisted.pushTarget = undefined
}
if (lineage?.noParent === true) {
args.store.removeWorktreeLineage?.(target)
} else if (lineage?.parentWorktree) {
const parent = await args.ports.resolveWorktree(lineage.parentWorktree)
args.ports.validateParent(worktree, parent)
nextParentRepoId = parent.repoId
if (!worktree.instanceId || !parent.instanceId) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_MISSING',
'Worktree instance identity was unavailable.'
)
}
if (!args.store.setWorktreeLineage) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_MISSING',
'Worktree lineage storage was unavailable.'
)
}
const createdAt = Date.now()
const recorded = args.store.setWorktreeLineage(target, {
worktreeId: worktree.id,
worktreeInstanceId: worktree.instanceId,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt
})
if (!recorded) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_CONFLICT',
'A same-id workspace on another host owns this lineage.'
)
}
args.store.setWorkspaceLineage?.({
childWorkspaceKey: worktreeWorkspaceKey(worktree.id),
childInstanceId: worktree.instanceId,
parentWorkspaceKey: worktreeWorkspaceKey(parent.id),
parentInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt
})
}
const metadataUpdates = stripOrcaProvenanceMetaUpdates(persisted)
// Why: a resolution snapshot taken before a removal still returns the workspace (#22712).
if (isRemovedFolderWorkspaceInstance(args.store, worktree.id, executionHostId)) {
throw new Error('selector_not_found')
}
args.store.setWorktreeMeta(target, metadataUpdates)
// Why: CLI callers need an explicit push for metadata changed outside the renderer's optimistic update path.
args.ports.invalidateResolved()
args.ports.notifyChanged(worktree.repoId)
if (lineage) {
for (const repoId of new Set([previousParentRepoId, nextParentRepoId])) {
if (repoId && repoId !== worktree.repoId) {
args.ports.notifyChanged(repoId)
}
}
}
return args.ports.showWorktree(`id:${worktree.id}`)
}
export function persistRuntimeManagedWorktreeSortOrder(args: {
orderedIds: WorktreeSortOrderRequest['orderedIds']
hostIds?: WorktreeSortOrderRequest['hostIds']
store: RuntimeStore
invalidateResolved: () => void
/** `sortOrders` omitted: the repo has same-id copies, which an id-keyed patch cannot tell apart. */
notifyChanged: (repoId: string, sortOrders?: WorktreeSortOrderUpdate[]) => void
}): { updated: number } {
// A moving project keeps its old order; the rest of the batch still lands. Only what was saved
// is reported and published.
const updates = writeWorktreeSortOrder(
args.store,
{ orderedIds: args.orderedIds, hostIds: args.hostIds },
(worktreeId) => isWslMoveWorkspaceBlocked(args.store, worktreeId),
Date.now()
)
if (updates.length === 0) {
return { updated: 0 }
}
args.invalidateResolved()
const updatesByRepo = new Map<string, WorktreeSortOrderUpdate[]>()
for (const update of updates) {
const repoId = splitWorktreeId(update.worktreeId)?.repoId
if (repoId) {
updatesByRepo.set(repoId, [...(updatesByRepo.get(repoId) ?? []), update])
}
}
for (const [repoId, sortOrders] of updatesByRepo) {
const hosts = new Set(
args.store
.getRepos()
.filter((repo) => repo.id === repoId)
.map(getRepoExecutionHostId)
)
args.notifyChanged(repoId, hosts.size > 1 ? undefined : sortOrders)
}
return { updated: updates.length }
}
function omitUndefinedProperties<T extends Record<string, unknown>>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined)
) as Partial<T>
}