mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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
This commit is contained in:
@@ -3,7 +3,10 @@ import type { CreateWorktreeArgs } from '../../shared/worktree/create-types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { recordWorkspaceLineageForCreatedWorktree } from './worktree-remote'
|
||||
import {
|
||||
assertAttachableParentWorkspace,
|
||||
recordWorkspaceLineageForCreatedWorktree
|
||||
} from './worktree-remote'
|
||||
|
||||
const CHILD_ID = 'repo-1::/repos/child'
|
||||
const PARENT_ID = 'repo-1::/repos/parent'
|
||||
@@ -209,3 +212,54 @@ describe('recordWorkspaceLineageForCreatedWorktree', () => {
|
||||
expect(result).toEqual({ lineage: null, workspaceLineage: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertAttachableParentWorkspace', () => {
|
||||
const childKey = worktreeWorkspaceKey(CHILD_ID)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function assertParent(
|
||||
store: ReturnType<typeof createStore>,
|
||||
parentWorkspace: CreateWorktreeArgs['parentWorkspace']
|
||||
) {
|
||||
assertAttachableParentWorkspace(store as never, parentWorkspace, childKey)
|
||||
}
|
||||
|
||||
// Why: the pick can go stale between the composer and the create; nesting is decoration, not the job.
|
||||
it.each([
|
||||
['worktree', () => worktreeWorkspaceKey(PARENT_ID)],
|
||||
['folder', () => folderWorkspaceKey('folder-gone')]
|
||||
])('degrades instead of failing when the %s parent disappeared', (_label, key) => {
|
||||
const store = createStore({})
|
||||
|
||||
expect(() => assertParent(store, key())).not.toThrow()
|
||||
expect(console.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a parent that still exists', () => {
|
||||
const store = createStore({ metaById: { [PARENT_ID]: parentMeta() } })
|
||||
|
||||
expect(() => assertParent(store, worktreeWorkspaceKey(PARENT_ID))).not.toThrow()
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a self-referential parent', () => {
|
||||
const store = createStore({ metaById: { [CHILD_ID]: parentMeta() } })
|
||||
|
||||
expect(() => assertParent(store, childKey)).toThrow(/cannot be attached to itself/)
|
||||
})
|
||||
|
||||
it('rejects a malformed parent key', () => {
|
||||
const store = createStore({})
|
||||
|
||||
expect(() => assertParent(store, 'not-a-workspace-key' as never)).toThrow(
|
||||
/Invalid parent workspace/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,7 +198,11 @@ function getSetupRunnerCommandPlatformForLaunch(
|
||||
return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform)
|
||||
}
|
||||
|
||||
function validateWorkspaceLineageParentBeforeCreate(
|
||||
/** Why not throw on a missing parent: nesting is optional decoration, and the pick can go stale
|
||||
* between the composer and the create. A malformed or self-referential key is a bad request and
|
||||
* still fails; a parent that simply disappeared degrades to an unattached workspace, matching the
|
||||
* runtime path and `recordWorkspaceLineageForCreatedWorktree`. */
|
||||
export function assertAttachableParentWorkspace(
|
||||
store: Store,
|
||||
parentWorkspace: CreateWorktreeArgs['parentWorkspace'],
|
||||
childWorkspaceKey: ReturnType<typeof worktreeWorkspaceKey>
|
||||
@@ -214,10 +218,11 @@ function validateWorkspaceLineageParentBeforeCreate(
|
||||
throw new Error(`Invalid parent workspace: ${parentWorkspace}`)
|
||||
}
|
||||
if (parentScope.type === 'folder' && !store.getFolderWorkspace(parentScope.folderWorkspaceId)) {
|
||||
throw new Error(`Parent folder workspace not found: ${parentWorkspace}`)
|
||||
console.warn(`[worktree-create] parent folder workspace not found: ${parentWorkspace}`)
|
||||
return
|
||||
}
|
||||
if (parentScope.type === 'worktree' && !store.getWorktreeMeta(parentScope.worktreeId)) {
|
||||
throw new Error(`Parent worktree workspace not found: ${parentWorkspace}`)
|
||||
console.warn(`[worktree-create] parent worktree workspace not found: ${parentWorkspace}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,7 +1751,7 @@ export async function createRemoteWorktree(
|
||||
)
|
||||
}
|
||||
|
||||
validateWorkspaceLineageParentBeforeCreate(
|
||||
assertAttachableParentWorkspace(
|
||||
store,
|
||||
args.parentWorkspace,
|
||||
worktreeWorkspaceKey(`${repo.id}::${remotePath}`)
|
||||
@@ -2389,7 +2394,7 @@ export async function createLocalWorktree(
|
||||
)
|
||||
}
|
||||
|
||||
validateWorkspaceLineageParentBeforeCreate(
|
||||
assertAttachableParentWorkspace(
|
||||
store,
|
||||
args.parentWorkspace,
|
||||
worktreeWorkspaceKey(`${repo.id}::${worktreePath}`)
|
||||
|
||||
@@ -6468,6 +6468,52 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the desktop composer sends `parentWorkspace` too, and a bare selector defaults to CLI
|
||||
// provenance — the same user action must not carry different cleanup semantics per host.
|
||||
it('records an app-selected parent workspace as a manual action', async () => {
|
||||
vi.mocked(addWorktree).mockClear()
|
||||
const created = {
|
||||
path: '/tmp/workspaces/manual-child',
|
||||
head: 'def',
|
||||
branch: 'refs/heads/manual-child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const childId = `${TEST_REPO_ID}::${created.path}`
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...createFolderWorkspaceRuntimeStore(),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
setWorkspaceLineage: vi.fn((lineage: WorkspaceLineage) => lineage)
|
||||
}
|
||||
computeWorktreePathMock.mockReturnValue(created.path)
|
||||
ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue)
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([created])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: TEST_REPO_ID,
|
||||
name: 'manual-child',
|
||||
baseBranch: 'origin/main',
|
||||
lineage: {
|
||||
parentWorkspace: TEST_FOLDER_WORKSPACE_KEY,
|
||||
parentWorkspaceOrigin: 'manual'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.workspaceLineage).toMatchObject({
|
||||
childWorkspaceKey: `worktree:${childId}`,
|
||||
parentWorkspaceKey: TEST_FOLDER_WORKSPACE_KEY,
|
||||
origin: 'manual',
|
||||
capture: { source: 'active-workspace', confidence: 'explicit' }
|
||||
})
|
||||
})
|
||||
|
||||
it('records folder workspace lineage inferred from environment context', async () => {
|
||||
vi.mocked(addWorktree).mockClear()
|
||||
const created = {
|
||||
|
||||
@@ -2885,6 +2885,8 @@ type ResolvedTerminalWorkspaceLaunchTarget = {
|
||||
|
||||
type WorktreeLineageInput = {
|
||||
parentWorkspace?: string
|
||||
/** Set by in-app parent pickers so the row is not recorded as a CLI flag. */
|
||||
parentWorkspaceOrigin?: 'manual'
|
||||
envParentWorkspace?: string
|
||||
parentWorktree?: string
|
||||
cwdParentWorktree?: string
|
||||
@@ -32501,11 +32503,20 @@ export class OrcaRuntimeService {
|
||||
|
||||
if (input.parentWorkspace) {
|
||||
try {
|
||||
const parent = await this.resolveWorkspaceParentSelector(input.parentWorkspace)
|
||||
// Why: a picker in the app must record the same provenance as a local create, or the same
|
||||
// user action would carry different cleanup semantics depending on where the repo lives.
|
||||
return {
|
||||
kind: 'lineage',
|
||||
parent: await this.resolveWorkspaceParentSelector(input.parentWorkspace),
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' }
|
||||
parent,
|
||||
origin: input.parentWorkspaceOrigin === 'manual' ? 'manual' : 'cli',
|
||||
capture:
|
||||
input.parentWorkspaceOrigin === 'manual'
|
||||
? {
|
||||
source: parent.type === 'worktree' ? 'manual-action' : 'active-workspace',
|
||||
confidence: 'explicit'
|
||||
}
|
||||
: { source: 'explicit-cli-flag', confidence: 'explicit' }
|
||||
}
|
||||
} catch (err) {
|
||||
throw new RuntimeLineageError(
|
||||
|
||||
@@ -26,4 +26,19 @@ describe('buildManagedWorktreeCreateArgs', () => {
|
||||
nameWasGenerated: true
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the parent-pick provenance only when the client marked it manual', () => {
|
||||
// Why: older clients never send it, and those creates really are CLI-flag equivalents.
|
||||
expect(
|
||||
build({ repo: 'id:repo-1', name: 'child', parentWorkspace: 'folder:f1' }).lineage
|
||||
).not.toHaveProperty('parentWorkspaceOrigin')
|
||||
expect(
|
||||
build({
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
parentWorkspace: 'folder:f1',
|
||||
parentWorkspaceOrigin: 'manual'
|
||||
}).lineage
|
||||
).toMatchObject({ parentWorkspaceOrigin: 'manual' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,6 +80,7 @@ export function buildManagedWorktreeCreateArgs(
|
||||
startupDraft: params.startupDraft,
|
||||
lineage: {
|
||||
parentWorkspace: params.parentWorkspace,
|
||||
...(params.parentWorkspaceOrigin ? { parentWorkspaceOrigin: 'manual' as const } : {}),
|
||||
envParentWorkspace: params.envParentWorkspace,
|
||||
parentWorktree: params.parentWorktree,
|
||||
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
|
||||
|
||||
@@ -75,6 +75,9 @@ export const WorktreeCreate = z
|
||||
// startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal.
|
||||
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
|
||||
parentWorkspace: OptionalString,
|
||||
// Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag.
|
||||
// Absent keeps the CLI provenance older clients rely on.
|
||||
parentWorkspaceOrigin: z.literal('manual').optional(),
|
||||
envParentWorkspace: OptionalString,
|
||||
parentWorktree: OptionalString,
|
||||
cwdParentWorktree: OptionalString,
|
||||
|
||||
@@ -1145,9 +1145,11 @@ html.native-shell .app-layout {
|
||||
|
||||
/* ── Jump palette (Cmd+J) ─────────────────────────────── */
|
||||
|
||||
/* Why the data-slot qualifier: CommandItem also sets data-[selected=true]:bg-accent;
|
||||
this selector must beat that utility so light-mode selection is actually visible. */
|
||||
[data-slot='command-item'].jump-palette-item[data-selected='true'] {
|
||||
/* Why the extra qualifier: CommandItem also sets data-[selected=true]:bg-accent;
|
||||
this selector must beat that utility so light-mode selection is actually visible.
|
||||
The role='option' arm covers virtualized rows that render outside cmdk. */
|
||||
[data-slot='command-item'].jump-palette-item[data-selected='true'],
|
||||
[role='option'].jump-palette-item[data-selected='true'] {
|
||||
/* Why: flat --accent (#f5f5f5) on the palette surface (#fff) is nearly invisible
|
||||
in light mode — the keyboard cursor has to read as a solid selected row.
|
||||
Mix foreground into background and draw an inset ring; expose the surface
|
||||
@@ -1159,7 +1161,8 @@ html.native-shell .app-layout {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.dark [data-slot='command-item'].jump-palette-item[data-selected='true'] {
|
||||
.dark [data-slot='command-item'].jump-palette-item[data-selected='true'],
|
||||
.dark [role='option'].jump-palette-item[data-selected='true'] {
|
||||
/* Why accent, not the light-mode mix: --accent (#404040) already reads clearly on
|
||||
dark surfaces; a low-%% foreground mix was a visible step down from the old row. */
|
||||
--jump-palette-selection-surface: var(--accent);
|
||||
|
||||
@@ -371,10 +371,8 @@ function ParentWorktreeCandidateList({
|
||||
role="option"
|
||||
aria-selected={(candidate?.id ?? null) === value}
|
||||
data-selected={isHighlighted || undefined}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 flex w-full cursor-default select-none items-center gap-2 overflow-hidden rounded-sm px-2 py-2 text-sm outline-none',
|
||||
isHighlighted && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
// Why `jump-palette-item`: selection chrome lives in main.css — flat accent is invisible on light popovers.
|
||||
className="jump-palette-item absolute left-0 top-0 flex w-full cursor-default select-none items-center gap-2 overflow-hidden rounded-sm px-2 py-2 text-sm outline-none"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
export type RuntimeEnvironmentCallRequest = {
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createCompatibleRuntimeStatusResponse(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CreateWorktreeCallOptions } from './worktrees/create/worktree-create-payload'
|
||||
import type { WorkspaceKey } from '../../../../shared/folder-workspace-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../../shared/workspace-source'
|
||||
@@ -7,7 +8,6 @@ import type {
|
||||
} from '../../../../shared/worktree/base-ref-drift-types'
|
||||
import type {
|
||||
CreateSparseCheckoutRequest,
|
||||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
ForceDeleteWorktreeBranchResult,
|
||||
SetupDecision
|
||||
@@ -19,11 +19,9 @@ import type {
|
||||
DetectedWorktree,
|
||||
DetectedWorktreeListResult,
|
||||
GitPushTarget,
|
||||
WorkspaceLinkedItem,
|
||||
WorkspaceStatus,
|
||||
Worktree
|
||||
} from '../../../../shared/worktree/types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
|
||||
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
@@ -198,22 +196,7 @@ export type WorktreeSlice = {
|
||||
linkedAzureDevOpsPR?: number | null,
|
||||
linkedGiteaPR?: number | null,
|
||||
compareBaseRef?: string,
|
||||
options?: {
|
||||
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
|
||||
linkedWorkItem?: WorkspaceLinkedItem | null
|
||||
linkedTaskSourceContext?: TaskSourceContext | null
|
||||
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
|
||||
startupDraft?: string
|
||||
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
|
||||
nameWasGenerated?: boolean
|
||||
/** Parent picked in the composer. Sets sidebar nesting only; ignored if it no longer exists. */
|
||||
parentWorktreeId?: string
|
||||
provisionedRoot?: {
|
||||
runtimeId: string
|
||||
executionHostId: ExecutionHostId
|
||||
expectedPath: string
|
||||
}
|
||||
}
|
||||
options?: CreateWorktreeCallOptions
|
||||
) => Promise<CreateWorktreeResult>
|
||||
/** Register an in-flight background creation and make it the active surface. */
|
||||
beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { LocalBaseRefRefreshResult } from '../../../../shared/worktree/base-ref-drift-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { toast } from 'sonner'
|
||||
import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { makeLineage, makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
import { makeWorkspaceLineage, makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
import {
|
||||
createTestStore,
|
||||
mockApi,
|
||||
@@ -727,131 +726,3 @@ describe('createWorktree base status merge', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorktree composer parent pick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
})
|
||||
|
||||
function createParentPickStore(parent?: Worktree) {
|
||||
const store = createTestStore()
|
||||
store.setState({ worktreesByRepo: { repo1: parent ? [parent] : [] } } as Partial<AppState>)
|
||||
return store
|
||||
}
|
||||
|
||||
function createWithParentPick(
|
||||
store: ReturnType<typeof createTestStore>,
|
||||
parentWorktreeId: string
|
||||
) {
|
||||
const createWorktree = store.getState().createWorktree
|
||||
const args: Parameters<typeof createWorktree> = ['repo1', 'feature', 'origin/main']
|
||||
args[25] = { parentWorktreeId }
|
||||
return createWorktree(...args)
|
||||
}
|
||||
|
||||
it('nests the new workspace under the picked parent worktree', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
|
||||
})
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentWorkspace: worktreeWorkspaceKey(parent.id) })
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a stale parent pick and falls back to the active folder workspace', async () => {
|
||||
const store = createParentPickStore()
|
||||
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
|
||||
})
|
||||
|
||||
await createWithParentPick(store, 'repo1::/path/removed')
|
||||
|
||||
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentWorkspace: folderWorkspaceKey('folder-1') })
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when an archived parent pick is dropped before create', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
displayName: 'parent-wt',
|
||||
isArchived: true
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
|
||||
})
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
'Created without nesting under "parent-wt"',
|
||||
expect.objectContaining({ description: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when the backend rejects an accepted parent pick', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
displayName: 'parent-wt',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' }),
|
||||
lineage: null
|
||||
})
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
'Created without nesting under "parent-wt"',
|
||||
expect.objectContaining({ description: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an accepted parent pick quiet and seeds its lineage', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
const created = makeWorktree({
|
||||
id: 'repo1::/path/child',
|
||||
repoId: 'repo1',
|
||||
path: '/path/child',
|
||||
instanceId: 'child-instance'
|
||||
})
|
||||
const lineage = makeLineage({
|
||||
worktreeId: created.id,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: 'parent-instance'
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
mockApi.worktrees.create.mockResolvedValue({ worktree: created, lineage })
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreeLineageById[created.id]).toEqual(lineage)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { toast } from 'sonner'
|
||||
import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { worktreeWorkspaceKey, folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { makeLineage, makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
import {
|
||||
createTestStore,
|
||||
mockApi,
|
||||
resetRemoteRuntimeMocks,
|
||||
resetWorktreeSliceModuleMemory,
|
||||
runtimeEnvironmentCall
|
||||
} from './worktrees-slice-test-harness'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
dismiss: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/worktree-base-fallback-notice', () => ({
|
||||
requestWorktreeBaseFallbackNotice: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(resetWorktreeSliceModuleMemory)
|
||||
|
||||
describe('createWorktree composer parent pick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
})
|
||||
|
||||
function createParentPickStore(parent?: Worktree) {
|
||||
const store = createTestStore()
|
||||
store.setState({ worktreesByRepo: { repo1: parent ? [parent] : [] } } as Partial<AppState>)
|
||||
return store
|
||||
}
|
||||
|
||||
function createWithParentPick(
|
||||
store: ReturnType<typeof createTestStore>,
|
||||
parentWorktreeId: string
|
||||
) {
|
||||
const createWorktree = store.getState().createWorktree
|
||||
const args: Parameters<typeof createWorktree> = ['repo1', 'feature', 'origin/main']
|
||||
args[25] = { parentWorktreeId }
|
||||
return createWorktree(...args)
|
||||
}
|
||||
|
||||
it('nests the new workspace under the picked parent worktree', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
|
||||
})
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentWorkspace: worktreeWorkspaceKey(parent.id) })
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a stale parent pick and falls back to the active folder workspace', async () => {
|
||||
const store = createParentPickStore()
|
||||
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
|
||||
})
|
||||
|
||||
await createWithParentPick(store, 'repo1::/path/removed')
|
||||
|
||||
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentWorkspace: folderWorkspaceKey('folder-1') })
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when an archived parent pick is dropped before create', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
displayName: 'parent-wt',
|
||||
isArchived: true
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
|
||||
})
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
'Created without nesting under "parent-wt"',
|
||||
expect.objectContaining({ description: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when the backend rejects an accepted parent pick', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
displayName: 'parent-wt',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' }),
|
||||
lineage: null
|
||||
})
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
'Created without nesting under "parent-wt"',
|
||||
expect.objectContaining({ description: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an accepted parent pick quiet and seeds its lineage', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
const created = makeWorktree({
|
||||
id: 'repo1::/path/child',
|
||||
repoId: 'repo1',
|
||||
path: '/path/child',
|
||||
instanceId: 'child-instance'
|
||||
})
|
||||
const lineage = makeLineage({
|
||||
worktreeId: created.id,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: 'parent-instance'
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
mockApi.worktrees.create.mockResolvedValue({ worktree: created, lineage })
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreeLineageById[created.id]).toEqual(lineage)
|
||||
})
|
||||
|
||||
// Why: paired web clients route window.api.worktrees.create to their host, which still rejects.
|
||||
it('retries without the parent when the create API reports the parent is gone', async () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo1::/path/parent',
|
||||
repoId: 'repo1',
|
||||
path: '/path/parent',
|
||||
displayName: 'parent-wt',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
const store = createParentPickStore(parent)
|
||||
mockApi.worktrees.create
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Parent selector was not found.'), {
|
||||
code: 'LINEAGE_PARENT_NOT_FOUND'
|
||||
})
|
||||
)
|
||||
.mockResolvedValue({
|
||||
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
|
||||
})
|
||||
|
||||
await createWithParentPick(store, parent.id)
|
||||
|
||||
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(2)
|
||||
expect(mockApi.worktrees.create.mock.calls[1][0]).not.toHaveProperty('parentWorkspace')
|
||||
expect(toast.warning).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('warns once when a dropped pick is followed by branch-conflict retries', async () => {
|
||||
const store = createParentPickStore()
|
||||
const created = makeWorktree({
|
||||
id: 'repo1::/path/child',
|
||||
repoId: 'repo1',
|
||||
path: '/path/child'
|
||||
})
|
||||
mockApi.worktrees.create
|
||||
.mockRejectedValueOnce(new Error('Branch "feature" already exists locally.'))
|
||||
.mockResolvedValue({ worktree: created })
|
||||
|
||||
await createWithParentPick(store, 'repo1::/path/removed')
|
||||
|
||||
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(2)
|
||||
expect(toast.warning).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorktree parent pick on a remote runtime', () => {
|
||||
const REMOTE_REPO = 'repo-remote'
|
||||
const PARENT_ID = `${REMOTE_REPO}::/remote/parent`
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
})
|
||||
|
||||
function createRemoteStore() {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: REMOTE_REPO,
|
||||
path: '/home/dev/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
[REMOTE_REPO]: [
|
||||
makeWorktree({
|
||||
id: PARENT_ID,
|
||||
repoId: REMOTE_REPO,
|
||||
path: '/remote/parent',
|
||||
displayName: 'parent-wt',
|
||||
instanceId: 'parent-instance'
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
return store
|
||||
}
|
||||
|
||||
function createOnRemote(store: ReturnType<typeof createTestStore>) {
|
||||
const createWorktree = store.getState().createWorktree
|
||||
const args: Parameters<typeof createWorktree> = [REMOTE_REPO, 'feature', 'origin/main']
|
||||
args[25] = { parentWorktreeId: PARENT_ID }
|
||||
return createWorktree(...args)
|
||||
}
|
||||
|
||||
function createCalls() {
|
||||
return runtimeEnvironmentCall.mock.calls
|
||||
.map(([request]: [RuntimeEnvironmentCallRequest]) => request)
|
||||
.filter((request) => request.method === 'worktree.create')
|
||||
}
|
||||
|
||||
function mockRuntimeCreate(firstCreate: (id: string) => unknown) {
|
||||
let createCount = 0
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => {
|
||||
if (method !== 'worktree.create') {
|
||||
return Promise.resolve({ id: method, ok: true, result: null })
|
||||
}
|
||||
createCount += 1
|
||||
return createCount === 1
|
||||
? Promise.resolve(firstCreate('rpc-create'))
|
||||
: Promise.resolve({
|
||||
id: 'rpc-create',
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: makeWorktree({
|
||||
id: `${REMOTE_REPO}::/remote/child`,
|
||||
repoId: REMOTE_REPO,
|
||||
path: '/remote/child'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('marks an app-selected parent as manual so the host does not record a CLI flag', async () => {
|
||||
mockRuntimeCreate((id) => ({
|
||||
id,
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: makeWorktree({
|
||||
id: `${REMOTE_REPO}::/remote/child`,
|
||||
repoId: REMOTE_REPO,
|
||||
path: '/remote/child'
|
||||
}),
|
||||
lineage: makeLineage({
|
||||
worktreeId: `${REMOTE_REPO}::/remote/child`,
|
||||
parentWorktreeId: PARENT_ID
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
await createOnRemote(createRemoteStore())
|
||||
|
||||
expect(createCalls()[0]?.params).toMatchObject({
|
||||
parentWorkspace: worktreeWorkspaceKey(PARENT_ID),
|
||||
parentWorkspaceOrigin: 'manual'
|
||||
})
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries without the parent and warns when the host no longer has it', async () => {
|
||||
mockRuntimeCreate((id) => ({
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: 'LINEAGE_PARENT_NOT_FOUND', message: 'Parent selector was not found.' }
|
||||
}))
|
||||
|
||||
await createOnRemote(createRemoteStore())
|
||||
|
||||
const calls = createCalls()
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1]?.params).not.toHaveProperty('parentWorkspace')
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
'Created without nesting under "parent-wt"',
|
||||
expect.objectContaining({ description: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('retries when a transport re-wraps the parent-missing code into a plain error', async () => {
|
||||
let createCount = 0
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => {
|
||||
if (method !== 'worktree.create') {
|
||||
return Promise.resolve({ id: method, ok: true, result: null })
|
||||
}
|
||||
createCount += 1
|
||||
// Why: the relay envelope re-throws as a plain Error, so only the trailing code survives.
|
||||
return createCount === 1
|
||||
? Promise.reject(new Error('Error invoking remote method: LINEAGE_PARENT_NOT_FOUND'))
|
||||
: Promise.resolve({
|
||||
id: 'rpc-create',
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: makeWorktree({
|
||||
id: `${REMOTE_REPO}::/remote/child`,
|
||||
repoId: REMOTE_REPO,
|
||||
path: '/remote/child'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await createOnRemote(createRemoteStore())
|
||||
|
||||
expect(createCalls()).toHaveLength(2)
|
||||
expect(toast.warning).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why: the folder parent comes from the active scope, not a pick, so nothing else would surface it.
|
||||
it('warns when the retry drops an active folder-workspace parent', async () => {
|
||||
const store = createRemoteStore()
|
||||
store.setState({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
folderWorkspaces: [{ id: 'folder-1', name: 'Design docs' }]
|
||||
} as unknown as Partial<AppState>)
|
||||
mockRuntimeCreate((id) => ({
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: 'LINEAGE_PARENT_NOT_FOUND', message: 'Parent selector was not found.' }
|
||||
}))
|
||||
const createWorktree = store.getState().createWorktree
|
||||
|
||||
await createWorktree(REMOTE_REPO, 'feature', 'origin/main')
|
||||
|
||||
const calls = createCalls()
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]?.params).toMatchObject({ parentWorkspace: folderWorkspaceKey('folder-1') })
|
||||
expect(calls[1]?.params).not.toHaveProperty('parentWorkspace')
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
'Created without nesting under "Design docs"',
|
||||
expect.objectContaining({ description: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('never double-creates when the host fails for an unrelated reason', async () => {
|
||||
mockRuntimeCreate((id) => ({
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: 'internal_error', message: 'disk on fire' }
|
||||
}))
|
||||
|
||||
await expect(createOnRemote(createRemoteStore())).rejects.toThrow('disk on fire')
|
||||
|
||||
expect(createCalls()).toHaveLength(1)
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,12 @@
|
||||
import type { WorktreeSlice } from '../../worktree-helpers'
|
||||
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
|
||||
import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types'
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../../../../shared/new-workspace/worktree-create-retry-policy'
|
||||
import {
|
||||
parseWorkspaceKey,
|
||||
folderWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../../../shared/workspace-scope'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name'
|
||||
import {
|
||||
assertRuntimeEnvironmentCapability,
|
||||
callRuntimeRpc,
|
||||
@@ -23,33 +16,84 @@ import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../.
|
||||
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
|
||||
import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice'
|
||||
import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast'
|
||||
import {
|
||||
getProjectHostSetupForRepoHost,
|
||||
repoHostId,
|
||||
withRepoHostOwnership
|
||||
} from '../listing/worktree-host-ownership'
|
||||
import { settingsForRepoOwner } from '../listing/worktree-owner-settings'
|
||||
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
import { applyCreatedWorktree } from './created-worktree-state-merge'
|
||||
import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors'
|
||||
import {
|
||||
buildLocalWorktreeCreateArgs,
|
||||
buildRuntimeWorktreeCreateParams,
|
||||
type WorktreeCreateAttempt,
|
||||
type WorktreeCreateRequest
|
||||
} from './worktree-create-payload'
|
||||
import {
|
||||
notifyWorktreeParentDropped,
|
||||
resolveWorktreeCreateParent,
|
||||
type WorktreeCreateParentPick
|
||||
} from './worktree-create-parent-pick'
|
||||
|
||||
function notifyPickedParentDropped(parentDisplayName: string | null): void {
|
||||
toast.warning(
|
||||
parentDisplayName
|
||||
? translate(
|
||||
'auto.store.slices.worktrees.createdWithoutParentNesting',
|
||||
'Created without nesting under "{{value0}}"',
|
||||
{ value0: parentDisplayName }
|
||||
)
|
||||
: translate(
|
||||
'auto.store.slices.worktrees.createdWithoutParentNestingUnnamed',
|
||||
'Created without nesting under the selected parent'
|
||||
),
|
||||
{
|
||||
description: translate(
|
||||
'auto.store.slices.worktrees.createdWithoutParentNestingDetail',
|
||||
'The parent workspace was no longer available. You can set it from the workspace menu.'
|
||||
)
|
||||
type RuntimeTarget = ReturnType<typeof getActiveRuntimeTarget>
|
||||
|
||||
type CreateAttemptOutcome = {
|
||||
result: CreateWorktreeResult
|
||||
/** The host had no such parent, so this attempt was retried unattached. */
|
||||
droppedParent: boolean
|
||||
}
|
||||
|
||||
async function runCreateAttempt(
|
||||
request: WorktreeCreateRequest,
|
||||
attempt: WorktreeCreateAttempt,
|
||||
target: RuntimeTarget
|
||||
): Promise<CreateAttemptOutcome> {
|
||||
const provisionedRoot = request.options?.provisionedRoot
|
||||
if (provisionedRoot) {
|
||||
return {
|
||||
result: await window.api.worktrees.adoptProvisionedRoot({
|
||||
...buildLocalWorktreeCreateArgs(request, attempt),
|
||||
...provisionedRoot
|
||||
}),
|
||||
droppedParent: false
|
||||
}
|
||||
}
|
||||
const create = async (
|
||||
parentWorkspace: WorktreeCreateAttempt['parentWorkspace']
|
||||
): Promise<CreateWorktreeResult> =>
|
||||
target.kind === 'local'
|
||||
? // Why local can still reject on the parent: paired web clients route this API to their host.
|
||||
await window.api.worktrees.create(
|
||||
buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace })
|
||||
)
|
||||
: await callRuntimeRpc<CreateWorktreeResult>(
|
||||
target,
|
||||
'worktree.create',
|
||||
buildRuntimeWorktreeCreateParams(request, { ...attempt, parentWorkspace }),
|
||||
{ timeoutMs: 10 * 60_000 }
|
||||
)
|
||||
try {
|
||||
return { result: await create(attempt.parentWorkspace), droppedParent: false }
|
||||
} catch (error) {
|
||||
if (!attempt.parentWorkspace || !isRuntimeLineageParentMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
return { result: await create(undefined), droppedParent: true }
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the create landed without the nesting the caller asked for. */
|
||||
function lostRequestedParent(
|
||||
outcome: CreateAttemptOutcome,
|
||||
parent: WorktreeCreateParentPick,
|
||||
target: RuntimeTarget
|
||||
): boolean {
|
||||
if (outcome.droppedParent) {
|
||||
return true
|
||||
}
|
||||
if (parent.pickedParentWorktreeId) {
|
||||
return !outcome.result.lineage
|
||||
}
|
||||
// Why local-only: older hosts predate the top-level workspace lineage field, so its absence
|
||||
// over RPC would warn about a nesting that actually landed.
|
||||
return (
|
||||
target.kind === 'local' && Boolean(parent.parentWorkspace) && !outcome.result.workspaceLineage
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,223 +129,87 @@ export function createCreateWorktree(
|
||||
compareBaseRef,
|
||||
options
|
||||
) => {
|
||||
const automationProvenanceRequest = options?.automationProvenanceRequest
|
||||
const linkedWorkItem = options?.linkedWorkItem
|
||||
const linkedTaskSourceContext = options?.linkedTaskSourceContext
|
||||
const startupDraft = options?.startupDraft
|
||||
const provisionedRoot = options?.provisionedRoot
|
||||
const request: WorktreeCreateRequest = {
|
||||
repoId,
|
||||
name,
|
||||
baseBranch,
|
||||
setupDecision,
|
||||
sparseCheckout,
|
||||
telemetrySource,
|
||||
displayName,
|
||||
linkedIssue,
|
||||
linkedPR,
|
||||
pushTarget,
|
||||
createdWithAgent,
|
||||
linkedLinearIssue,
|
||||
branchNameOverride,
|
||||
workspaceStatus,
|
||||
linkedGitLabMR,
|
||||
linkedGitLabIssue,
|
||||
startup,
|
||||
pendingFirstAgentMessageRename,
|
||||
creationId,
|
||||
linkedLinearIssueWorkspaceId,
|
||||
linkedLinearIssueOrganizationUrlKey,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR,
|
||||
compareBaseRef,
|
||||
options
|
||||
}
|
||||
try {
|
||||
// Why outside the retry loop: a branch-name conflict retry must not re-warn about the same dropped pick.
|
||||
const parent = resolveWorktreeCreateParent(get(), repoId, options?.parentWorktreeId)
|
||||
let warnedParentDropped = false
|
||||
const warnParentDroppedOnce = (): void => {
|
||||
if (warnedParentDropped) {
|
||||
return
|
||||
}
|
||||
warnedParentDropped = true
|
||||
notifyWorktreeParentDropped(get(), parent)
|
||||
}
|
||||
if (parent.staleBeforeCreate) {
|
||||
warnParentDroppedOnce()
|
||||
}
|
||||
// Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback.
|
||||
const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined
|
||||
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
|
||||
if (
|
||||
target.kind === 'environment' &&
|
||||
(options?.linkedWorkItem?.provider === 'jira' ||
|
||||
options?.linkedTaskSourceContext?.provider === 'jira')
|
||||
) {
|
||||
await assertRuntimeEnvironmentCapability(
|
||||
target.environmentId,
|
||||
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
|
||||
'Update the remote runtime to link Jira'
|
||||
)
|
||||
}
|
||||
if (options?.provisionedRoot && target.kind !== 'local') {
|
||||
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
|
||||
}
|
||||
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const candidateName = options?.nameWasGenerated
|
||||
? getGeneratedWorktreeCreateRetryCandidate(name, attempt)
|
||||
: getClientWorktreeCreateCandidate(name, attempt)
|
||||
// Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names.
|
||||
const candidateBranchNameOverride = branchNameOverride
|
||||
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
|
||||
: undefined
|
||||
try {
|
||||
// Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback.
|
||||
const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined
|
||||
const activeScope = parseWorkspaceKey(get().activeWorkspaceKey ?? '')
|
||||
const pickedParent = options?.parentWorktreeId
|
||||
? getIndexedWorktreeById(get().worktreesByRepo, options.parentWorktreeId)
|
||||
: undefined
|
||||
const pickedParentWorktreeId =
|
||||
pickedParent && !pickedParent.isArchived && pickedParent.repoId === repoId
|
||||
? pickedParent.id
|
||||
: undefined
|
||||
if (options?.parentWorktreeId && !pickedParentWorktreeId) {
|
||||
notifyPickedParentDropped(
|
||||
pickedParent ? resolveWorktreeDisplayName(pickedParent).trim() : null
|
||||
)
|
||||
const outcome = await runCreateAttempt(
|
||||
request,
|
||||
{
|
||||
name: options?.nameWasGenerated
|
||||
? getGeneratedWorktreeCreateRetryCandidate(name, attempt)
|
||||
: getClientWorktreeCreateCandidate(name, attempt),
|
||||
// Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names.
|
||||
branchNameOverride: branchNameOverride
|
||||
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
|
||||
: undefined,
|
||||
parentWorkspace: parent.parentWorkspace,
|
||||
manualOrder
|
||||
},
|
||||
target
|
||||
)
|
||||
if (lostRequestedParent(outcome, parent, target)) {
|
||||
warnParentDroppedOnce()
|
||||
}
|
||||
const parentWorkspace = pickedParentWorktreeId
|
||||
? worktreeWorkspaceKey(pickedParentWorktreeId)
|
||||
: activeScope?.type === 'folder'
|
||||
? folderWorkspaceKey(activeScope.folderWorkspaceId)
|
||||
: undefined
|
||||
const createArgs = {
|
||||
repoId,
|
||||
name: candidateName,
|
||||
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
baseBranch,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
...(candidateBranchNameOverride
|
||||
? { branchNameOverride: candidateBranchNameOverride }
|
||||
: {}),
|
||||
setupDecision,
|
||||
sparseCheckout,
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(telemetrySource ? { telemetrySource } : {}),
|
||||
...(linkedIssue !== undefined ? { linkedIssue } : {}),
|
||||
...(linkedPR !== undefined ? { linkedPR } : {}),
|
||||
...(pushTarget ? { pushTarget } : {}),
|
||||
...(createdWithAgent ? { createdWithAgent } : {}),
|
||||
...(pendingFirstAgentMessageRename === true && createdWithAgent
|
||||
? { pendingFirstAgentMessageRename: true }
|
||||
: {}),
|
||||
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
|
||||
...(linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId } : {}),
|
||||
...(linkedLinearIssueOrganizationUrlKey !== undefined
|
||||
? { linkedLinearIssueOrganizationUrlKey }
|
||||
: {}),
|
||||
...(manualOrder !== undefined ? { manualOrder } : {}),
|
||||
...(parentWorkspace ? { parentWorkspace } : {}),
|
||||
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
|
||||
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
|
||||
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
|
||||
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
|
||||
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
|
||||
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
|
||||
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
|
||||
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
|
||||
...(startup ? { startup } : {}),
|
||||
...(creationId ? { creationId } : {}),
|
||||
...(automationProvenanceRequest ? { automationProvenanceRequest } : {})
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
|
||||
if (
|
||||
target.kind === 'environment' &&
|
||||
(linkedWorkItem?.provider === 'jira' || linkedTaskSourceContext?.provider === 'jira')
|
||||
) {
|
||||
await assertRuntimeEnvironmentCapability(
|
||||
target.environmentId,
|
||||
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
|
||||
'Update the remote runtime to link Jira'
|
||||
)
|
||||
}
|
||||
if (provisionedRoot && target.kind !== 'local') {
|
||||
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
|
||||
}
|
||||
const createOnRuntimeTarget = async (
|
||||
parentWorkspaceForCall: typeof parentWorkspace
|
||||
): Promise<Awaited<ReturnType<typeof window.api.worktrees.create>>> =>
|
||||
await callRuntimeRpc<Awaited<ReturnType<typeof window.api.worktrees.create>>>(
|
||||
target,
|
||||
'worktree.create',
|
||||
{
|
||||
repo: repoId,
|
||||
name: candidateName,
|
||||
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
baseBranch,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
...(candidateBranchNameOverride
|
||||
? { branchNameOverride: candidateBranchNameOverride }
|
||||
: {}),
|
||||
setupDecision,
|
||||
sparseCheckout,
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(telemetrySource ? { telemetrySource } : {}),
|
||||
...(linkedIssue !== undefined ? { linkedIssue } : {}),
|
||||
...(linkedPR !== undefined ? { linkedPR } : {}),
|
||||
...(pushTarget ? { pushTarget } : {}),
|
||||
...(createdWithAgent ? { createdWithAgent } : {}),
|
||||
...(pendingFirstAgentMessageRename === true && createdWithAgent
|
||||
? { pendingFirstAgentMessageRename: true }
|
||||
: {}),
|
||||
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
|
||||
...(linkedLinearIssueWorkspaceId !== undefined
|
||||
? { linkedLinearIssueWorkspaceId }
|
||||
: {}),
|
||||
...(linkedLinearIssueOrganizationUrlKey !== undefined
|
||||
? { linkedLinearIssueOrganizationUrlKey }
|
||||
: {}),
|
||||
...(manualOrder !== undefined ? { manualOrder } : {}),
|
||||
...(parentWorkspaceForCall ? { parentWorkspace: parentWorkspaceForCall } : {}),
|
||||
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
|
||||
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
|
||||
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
|
||||
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
|
||||
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
|
||||
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
|
||||
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
|
||||
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
|
||||
...(startupDraft ? { startupDraft } : {}),
|
||||
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
|
||||
...(startup
|
||||
? {
|
||||
startupCommand: startup.command,
|
||||
...(startup.env ? { startupEnv: startup.env } : {}),
|
||||
...(startup.launchConfig
|
||||
? { startupLaunchConfig: startup.launchConfig }
|
||||
: {}),
|
||||
...(startup.startupCommandDelivery
|
||||
? { startupCommandDelivery: startup.startupCommandDelivery }
|
||||
: {}),
|
||||
activate: true
|
||||
}
|
||||
: {})
|
||||
},
|
||||
{ timeoutMs: 10 * 60_000 }
|
||||
)
|
||||
const result = provisionedRoot
|
||||
? await window.api.worktrees.adoptProvisionedRoot({
|
||||
...createArgs,
|
||||
...provisionedRoot
|
||||
})
|
||||
: target.kind === 'local'
|
||||
? await window.api.worktrees.create(createArgs)
|
||||
: await createOnRuntimeTarget(parentWorkspace).catch(async (error: unknown) => {
|
||||
if (!parentWorkspace || !isRuntimeLineageParentMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
return await createOnRuntimeTarget(undefined)
|
||||
})
|
||||
if (pickedParentWorktreeId && !result.lineage) {
|
||||
notifyPickedParentDropped(
|
||||
pickedParent ? resolveWorktreeDisplayName(pickedParent).trim() : null
|
||||
)
|
||||
}
|
||||
// Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash).
|
||||
set((s) => {
|
||||
const hostId = repoHostId(s, repoId)
|
||||
const createdWorktree = withRepoHostOwnership(
|
||||
result.worktree,
|
||||
hostId,
|
||||
getProjectHostSetupForRepoHost(s, repoId, hostId)
|
||||
)
|
||||
const current = s.worktreesByRepo[repoId] ?? []
|
||||
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
|
||||
const nextWorktrees = alreadyPresent
|
||||
? current.map((worktree) =>
|
||||
worktree.id === createdWorktree.id
|
||||
? { ...worktree, ...createdWorktree }
|
||||
: worktree
|
||||
)
|
||||
: [...current, createdWorktree]
|
||||
return {
|
||||
worktreesByRepo: {
|
||||
...s.worktreesByRepo,
|
||||
[repoId]: nextWorktrees
|
||||
},
|
||||
...(result.workspaceLineage
|
||||
? {
|
||||
workspaceLineageByChildKey: {
|
||||
...s.workspaceLineageByChildKey,
|
||||
[result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(result.lineage
|
||||
? {
|
||||
worktreeLineageById: {
|
||||
...s.worktreeLineageById,
|
||||
[result.lineage.worktreeId]: result.lineage
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(result.initialBaseStatus
|
||||
? {
|
||||
baseStatusByWorktreeId: {
|
||||
...s.baseStatusByWorktreeId,
|
||||
[result.worktree.id]:
|
||||
s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
sortEpoch: s.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
applyCreatedWorktree(set, repoId, outcome.result)
|
||||
const { result } = outcome
|
||||
showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree)
|
||||
if (result.baseFallback) {
|
||||
requestWorktreeBaseFallbackNotice(result.baseFallback)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { WorktreeSliceSet } from '../listing/worktree-slice-types'
|
||||
import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types'
|
||||
import {
|
||||
getProjectHostSetupForRepoHost,
|
||||
repoHostId,
|
||||
withRepoHostOwnership
|
||||
} from '../listing/worktree-host-ownership'
|
||||
|
||||
/** Folds a fresh create result into the worktree, lineage and base-status maps. */
|
||||
export function applyCreatedWorktree(
|
||||
set: WorktreeSliceSet,
|
||||
repoId: string,
|
||||
result: CreateWorktreeResult
|
||||
) {
|
||||
// Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash).
|
||||
set((s) => {
|
||||
const hostId = repoHostId(s, repoId)
|
||||
const createdWorktree = withRepoHostOwnership(
|
||||
result.worktree,
|
||||
hostId,
|
||||
getProjectHostSetupForRepoHost(s, repoId, hostId)
|
||||
)
|
||||
const current = s.worktreesByRepo[repoId] ?? []
|
||||
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
|
||||
const nextWorktrees = alreadyPresent
|
||||
? current.map((worktree) =>
|
||||
worktree.id === createdWorktree.id ? { ...worktree, ...createdWorktree } : worktree
|
||||
)
|
||||
: [...current, createdWorktree]
|
||||
return {
|
||||
worktreesByRepo: {
|
||||
...s.worktreesByRepo,
|
||||
[repoId]: nextWorktrees
|
||||
},
|
||||
...(result.workspaceLineage
|
||||
? {
|
||||
workspaceLineageByChildKey: {
|
||||
...s.workspaceLineageByChildKey,
|
||||
[result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(result.lineage
|
||||
? {
|
||||
worktreeLineageById: {
|
||||
...s.worktreeLineageById,
|
||||
[result.lineage.worktreeId]: result.lineage
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(result.initialBaseStatus
|
||||
? {
|
||||
baseStatusByWorktreeId: {
|
||||
...s.baseStatusByWorktreeId,
|
||||
[result.worktree.id]:
|
||||
s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
sortEpoch: s.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AppState } from '../../../types'
|
||||
import type { WorkspaceKey } from '../../../../../../shared/folder-workspace-types'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name'
|
||||
import {
|
||||
folderWorkspaceKey,
|
||||
parseWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../../../shared/workspace-scope'
|
||||
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
|
||||
export type WorktreeCreateParentPick = {
|
||||
/** Workspace the create attaches to. Undefined once a stale pick is dropped. */
|
||||
parentWorkspace?: WorkspaceKey
|
||||
/** Set only for an accepted user pick, whose lineage row must come back from the host. */
|
||||
pickedParentWorktreeId?: string
|
||||
/** Label for the drop warning; null when the parent record is already gone. */
|
||||
pickedDisplayName: string | null
|
||||
/** The pick no longer qualifies, so the create runs unattached. */
|
||||
staleBeforeCreate: boolean
|
||||
}
|
||||
|
||||
/** Resolves the composer pick against the current store, falling back to the active folder scope. */
|
||||
export function resolveWorktreeCreateParent(
|
||||
state: AppState,
|
||||
repoId: string,
|
||||
requestedParentWorktreeId: string | undefined
|
||||
): WorktreeCreateParentPick {
|
||||
const picked = requestedParentWorktreeId
|
||||
? getIndexedWorktreeById(state.worktreesByRepo, requestedParentWorktreeId)
|
||||
: undefined
|
||||
const usable = picked && !picked.isArchived && picked.repoId === repoId ? picked.id : undefined
|
||||
const pickedDisplayName = picked ? resolveWorktreeDisplayName(picked).trim() : null
|
||||
if (usable) {
|
||||
return {
|
||||
parentWorkspace: worktreeWorkspaceKey(usable),
|
||||
pickedParentWorktreeId: usable,
|
||||
pickedDisplayName,
|
||||
staleBeforeCreate: false
|
||||
}
|
||||
}
|
||||
const activeScope = parseWorkspaceKey(state.activeWorkspaceKey ?? '')
|
||||
return {
|
||||
parentWorkspace:
|
||||
activeScope?.type === 'folder'
|
||||
? folderWorkspaceKey(activeScope.folderWorkspaceId)
|
||||
: undefined,
|
||||
pickedDisplayName,
|
||||
staleBeforeCreate: Boolean(requestedParentWorktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved late so the common create path never walks the folder-workspace list, and defensively
|
||||
* so a missing label can never fail a create that already succeeded. */
|
||||
function parentLabel(state: AppState, parent: WorktreeCreateParentPick): string | null {
|
||||
if (parent.pickedDisplayName) {
|
||||
return parent.pickedDisplayName
|
||||
}
|
||||
const scope = parent.parentWorkspace ? parseWorkspaceKey(parent.parentWorkspace) : null
|
||||
if (scope?.type !== 'folder') {
|
||||
return null
|
||||
}
|
||||
const workspace = state.folderWorkspaces?.find(
|
||||
(candidate) => candidate.id === scope.folderWorkspaceId
|
||||
)
|
||||
return workspace?.name.trim() || null
|
||||
}
|
||||
|
||||
export function notifyWorktreeParentDropped(
|
||||
state: AppState,
|
||||
parent: WorktreeCreateParentPick
|
||||
): void {
|
||||
const parentDisplayName = parentLabel(state, parent)
|
||||
toast.warning(
|
||||
parentDisplayName
|
||||
? translate(
|
||||
'auto.store.slices.worktrees.createdWithoutParentNesting',
|
||||
'Created without nesting under "{{value0}}"',
|
||||
{ value0: parentDisplayName }
|
||||
)
|
||||
: translate(
|
||||
'auto.store.slices.worktrees.createdWithoutParentNestingUnnamed',
|
||||
'Created without nesting under the selected parent'
|
||||
),
|
||||
{
|
||||
description: translate(
|
||||
'auto.store.slices.worktrees.createdWithoutParentNestingDetail',
|
||||
'The parent workspace was no longer available. You can set it from the workspace menu.'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { CreateWorktreeArgs } from '../../../../../../shared/worktree/create-types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { WorkspaceKey } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { TaskSourceContext } from '../../../../../../shared/task-source-context'
|
||||
import type { WorkspaceLinkedItem } from '../../../../../../shared/worktree/types'
|
||||
|
||||
/** Trailing bag for `createWorktree` args that outgrew its positional list. */
|
||||
export type CreateWorktreeCallOptions = {
|
||||
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
|
||||
linkedWorkItem?: WorkspaceLinkedItem | null
|
||||
linkedTaskSourceContext?: TaskSourceContext | null
|
||||
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
|
||||
startupDraft?: string
|
||||
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
|
||||
nameWasGenerated?: boolean
|
||||
/** Parent picked in the composer. Sets sidebar nesting only; ignored if it no longer exists. */
|
||||
parentWorktreeId?: string
|
||||
provisionedRoot?: {
|
||||
runtimeId: string
|
||||
executionHostId: ExecutionHostId
|
||||
expectedPath: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything `createWorktree` received, packed once so both transports read from the same record.
|
||||
* The per-attempt fields live on `WorktreeCreateAttempt` instead. */
|
||||
export type WorktreeCreateRequest = Omit<CreateWorktreeArgs, 'parentWorkspace' | 'manualOrder'> & {
|
||||
options?: CreateWorktreeCallOptions
|
||||
}
|
||||
|
||||
/** Per-attempt values: names carry the conflict-retry suffix, the parent can be dropped on retry. */
|
||||
export type WorktreeCreateAttempt = {
|
||||
name: string
|
||||
branchNameOverride?: string
|
||||
parentWorkspace?: WorkspaceKey
|
||||
manualOrder?: number
|
||||
}
|
||||
|
||||
/** Fields both transports spell the same way; one builder keeps IPC and RPC payloads from drifting. */
|
||||
function sharedCreateFields(
|
||||
request: WorktreeCreateRequest,
|
||||
attempt: WorktreeCreateAttempt
|
||||
): Omit<CreateWorktreeArgs, 'repoId' | 'startup' | 'creationId'> {
|
||||
const { options } = request
|
||||
return {
|
||||
name: attempt.name,
|
||||
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
baseBranch: request.baseBranch,
|
||||
...(request.compareBaseRef ? { compareBaseRef: request.compareBaseRef } : {}),
|
||||
...(attempt.branchNameOverride ? { branchNameOverride: attempt.branchNameOverride } : {}),
|
||||
setupDecision: request.setupDecision,
|
||||
sparseCheckout: request.sparseCheckout,
|
||||
...(request.displayName ? { displayName: request.displayName } : {}),
|
||||
...(request.telemetrySource ? { telemetrySource: request.telemetrySource } : {}),
|
||||
...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}),
|
||||
...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}),
|
||||
...(request.pushTarget ? { pushTarget: request.pushTarget } : {}),
|
||||
...(request.createdWithAgent ? { createdWithAgent: request.createdWithAgent } : {}),
|
||||
...(request.pendingFirstAgentMessageRename === true && request.createdWithAgent
|
||||
? { pendingFirstAgentMessageRename: true }
|
||||
: {}),
|
||||
...(request.linkedLinearIssue !== undefined
|
||||
? { linkedLinearIssue: request.linkedLinearIssue }
|
||||
: {}),
|
||||
...(request.linkedLinearIssueWorkspaceId !== undefined
|
||||
? { linkedLinearIssueWorkspaceId: request.linkedLinearIssueWorkspaceId }
|
||||
: {}),
|
||||
...(request.linkedLinearIssueOrganizationUrlKey !== undefined
|
||||
? {
|
||||
linkedLinearIssueOrganizationUrlKey: request.linkedLinearIssueOrganizationUrlKey
|
||||
}
|
||||
: {}),
|
||||
...(attempt.manualOrder !== undefined ? { manualOrder: attempt.manualOrder } : {}),
|
||||
...(attempt.parentWorkspace ? { parentWorkspace: attempt.parentWorkspace } : {}),
|
||||
...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}),
|
||||
...(request.linkedGitLabMR !== undefined ? { linkedGitLabMR: request.linkedGitLabMR } : {}),
|
||||
...(request.linkedGitLabIssue !== undefined
|
||||
? { linkedGitLabIssue: request.linkedGitLabIssue }
|
||||
: {}),
|
||||
...(request.linkedBitbucketPR !== undefined
|
||||
? { linkedBitbucketPR: request.linkedBitbucketPR }
|
||||
: {}),
|
||||
...(request.linkedAzureDevOpsPR !== undefined
|
||||
? { linkedAzureDevOpsPR: request.linkedAzureDevOpsPR }
|
||||
: {}),
|
||||
...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}),
|
||||
...(options?.linkedWorkItem !== undefined ? { linkedWorkItem: options.linkedWorkItem } : {}),
|
||||
...(options?.linkedTaskSourceContext !== undefined
|
||||
? { linkedTaskSourceContext: options.linkedTaskSourceContext }
|
||||
: {}),
|
||||
...(options?.automationProvenanceRequest
|
||||
? { automationProvenanceRequest: options.automationProvenanceRequest }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalWorktreeCreateArgs(
|
||||
request: WorktreeCreateRequest,
|
||||
attempt: WorktreeCreateAttempt
|
||||
): CreateWorktreeArgs {
|
||||
return {
|
||||
repoId: request.repoId,
|
||||
...sharedCreateFields(request, attempt),
|
||||
...(request.startup ? { startup: request.startup } : {}),
|
||||
...(request.creationId ? { creationId: request.creationId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeWorktreeCreateParams(
|
||||
request: WorktreeCreateRequest,
|
||||
attempt: WorktreeCreateAttempt
|
||||
): Record<string, unknown> {
|
||||
const { startup, options } = request
|
||||
return {
|
||||
repo: request.repoId,
|
||||
...sharedCreateFields(request, attempt),
|
||||
// Why: the host defaults a bare `parentWorkspace` to CLI provenance; app picks are manual.
|
||||
...(attempt.parentWorkspace ? { parentWorkspaceOrigin: 'manual' } : {}),
|
||||
...(options?.startupDraft ? { startupDraft: options.startupDraft } : {}),
|
||||
...(startup
|
||||
? {
|
||||
startupCommand: startup.command,
|
||||
...(startup.env ? { startupEnv: startup.env } : {}),
|
||||
...(startup.launchConfig ? { startupLaunchConfig: startup.launchConfig } : {}),
|
||||
...(startup.startupCommandDelivery
|
||||
? { startupCommandDelivery: startup.startupCommandDelivery }
|
||||
: {}),
|
||||
activate: true
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ export function isRuntimeRepoNotFoundError(error: unknown): boolean {
|
||||
return hasRuntimeRpcErrorCode(error, 'repo_not_found')
|
||||
}
|
||||
|
||||
/** Thrown before the worktree exists, so the caller can safely retry without the parent. */
|
||||
/** Thrown before the worktree exists, so the caller can safely retry without the parent.
|
||||
* Matches re-wrapped errors too: relay and environment transports lose the RuntimeRpcCallError. */
|
||||
export function isRuntimeLineageParentMissingError(error: unknown): boolean {
|
||||
return error instanceof RuntimeRpcCallError && error.code === 'LINEAGE_PARENT_NOT_FOUND'
|
||||
return hasRuntimeRpcErrorCode(error, 'LINEAGE_PARENT_NOT_FOUND')
|
||||
}
|
||||
|
||||
@@ -78,7 +78,9 @@ export async function callRuntimeResult<TResult>(
|
||||
): Promise<TResult> {
|
||||
const response = await callRuntimeEnvelope(method, params, timeoutMs)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
// Why keep the code: callers classify recoverable host failures by token, and the message alone
|
||||
// (e.g. "Parent selector was not found.") carries none.
|
||||
throw Object.assign(new Error(response.error.message), { code: response.error.code })
|
||||
}
|
||||
return response.result as TResult
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
|
||||
}
|
||||
: {}),
|
||||
parentWorkspace: args.parentWorkspace,
|
||||
// Why: every create through this API is an in-app action, never the CLI's parent flag.
|
||||
...(args.parentWorkspace ? { parentWorkspaceOrigin: 'manual' } : {}),
|
||||
workspaceStatus: args.workspaceStatus,
|
||||
manualOrder: args.manualOrder,
|
||||
automationProvenanceRequest: args.automationProvenanceRequest
|
||||
|
||||
@@ -677,6 +677,49 @@ describe('web worktree preload API', () => {
|
||||
expect(runtimeCalls[0]?.params).not.toHaveProperty('nameWasGenerated')
|
||||
})
|
||||
|
||||
// Why: without it the host records the app's parent pick as a CLI flag, which cleans up differently.
|
||||
it('marks a parent-picked create as a manual action', async () => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
|
||||
runtimeCalls.push({ method, params })
|
||||
return Promise.resolve({
|
||||
id: `call-${runtimeCalls.length}`,
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/workspace/child', path: '/workspace/child' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
await globals.window.api.worktrees.create({
|
||||
repoId: 'repo-1',
|
||||
name: 'child',
|
||||
setupDecision: 'inherit'
|
||||
})
|
||||
await globals.window.api.worktrees.create({
|
||||
repoId: 'repo-1',
|
||||
name: 'child',
|
||||
setupDecision: 'inherit',
|
||||
parentWorkspace: 'folder:folder-1'
|
||||
})
|
||||
|
||||
expect(runtimeCalls[0]?.params).not.toHaveProperty('parentWorkspaceOrigin')
|
||||
expect(runtimeCalls[1]?.params).toMatchObject({
|
||||
parentWorkspace: 'folder:folder-1',
|
||||
parentWorkspaceOrigin: 'manual'
|
||||
})
|
||||
})
|
||||
|
||||
it('encodes explicit push target clears for runtime worktree updates', async () => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
|
||||
Reference in New Issue
Block a user