From 8c1a24089dfbbe6d0a49428cec33d5ae5cecf2f7 Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:16:42 +1100 Subject: [PATCH] fix(worktree): gate sessions on bootstrap readiness (#1762) Co-authored-by: Leonid Skorobogatyy Co-authored-by: Bohdan Triapitsyn --- .../openchamber/WorktreeSectionContent.tsx | 39 +++++++++++++- .../components/session/NewWorktreeDialog.tsx | 7 ++- .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/openchamberConfig.ts | 10 ++++ packages/ui/src/lib/settings/search.ts | 6 +++ packages/ui/src/lib/worktreeSessionCreator.ts | 13 ++++- .../ui/src/stores/useMultiRunStore.test.ts | 53 ++++++++++++++++--- packages/ui/src/stores/useMultiRunStore.ts | 7 ++- packages/ui/src/sync/session-ui-store.ts | 29 ++++++++-- packages/vscode/src/gitService.ts | 18 +++++++ packages/web/server/lib/git/service.js | 18 +++++++ packages/web/server/lib/git/service.test.js | 48 +++++++++++++++++ 20 files changed, 252 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 355d3726..2edcf0f8 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; @@ -9,7 +10,12 @@ import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDeviceInfo } from '@/lib/device'; import { checkIsGitRepository } from '@/lib/gitApi'; -import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; +import { + getWorktreeSetupCommands, + getWorktreeSetupWaitEnabled, + saveWorktreeSetupCommands, + saveWorktreeSetupWaitEnabled, +} from '@/lib/openchamberConfig'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { sessionEvents } from '@/lib/sessionEvents'; import type { WorktreeMetadata } from '@/types/worktree'; @@ -33,6 +39,7 @@ export const WorktreeSectionContent: React.FC = ({ const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const [setupCommands, setSetupCommands] = React.useState([]); + const [waitForSetupCommands, setWaitForSetupCommands] = React.useState(false); const [isLoadingCommands, setIsLoadingCommands] = React.useState(false); const [isGitRepoLocal, setIsGitRepoLocal] = React.useState(null); const [availableWorktrees, setAvailableWorktrees] = React.useState([]); @@ -127,13 +134,18 @@ export const WorktreeSectionContent: React.FC = ({ (async () => { try { - const commands = await getWorktreeSetupCommands(projectRef); + const [commands, waitForSetup] = await Promise.all([ + getWorktreeSetupCommands(projectRef), + getWorktreeSetupWaitEnabled(projectRef), + ]); if (!cancelled) { setSetupCommands(commands.length > 0 ? commands : ['']); + setWaitForSetupCommands(waitForSetup); } } catch { if (!cancelled) { setSetupCommands(['']); + setWaitForSetupCommands(false); } } finally { if (!cancelled) { @@ -179,6 +191,13 @@ export const WorktreeSectionContent: React.FC = ({ void persistSetupCommands(setupCommands); }, [persistSetupCommands, setupCommands]); + const handleWaitForSetupCommandsChange = React.useCallback((enabled: boolean) => { + setWaitForSetupCommands(enabled); + if (projectRef) { + void saveWorktreeSetupWaitEnabled(projectRef, enabled); + } + }, [projectRef]); + // Delete worktree handler const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => { const normalize = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, ''); @@ -322,6 +341,22 @@ export const WorktreeSectionContent: React.FC = ({ {t('settings.openchamber.worktrees.setup.addCommand')} + )} diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 70be749d..1d6ddb34 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -33,7 +33,8 @@ import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; -import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; +import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; +import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { renderMagicPrompt } from '@/lib/magicPrompts'; @@ -856,6 +857,10 @@ export function NewWorktreeDialog({ let createdSessionId: string | null = null; if (shouldCreateSession) { + if (await getWorktreeSetupWaitEnabled(projectRef)) { + await waitForWorktreeBootstrap(metadata.path); + } + const sessionTitle = linkedIssue ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 7ea4b78d..108a193a 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1124,6 +1124,8 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.commandPlaceholder': 'e.g., bun install', 'settings.openchamber.worktrees.setup.removeCommandAria': 'Remove command', 'settings.openchamber.worktrees.setup.addCommand': 'Add command', + 'settings.openchamber.worktrees.setup.waitForCommands': 'Wait for setup commands before creating or sending a session', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Wait for Worktree setup commands before creating or sending a session', 'settings.openchamber.worktrees.list.title': 'Existing worktrees', 'settings.openchamber.worktrees.list.tooltip': 'Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.', 'settings.openchamber.worktrees.list.loading': 'Loading worktrees...', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index e67423ea..f93ae94e 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1091,6 +1091,8 @@ export const settingsDict = { "settings.openchamber.worktrees.setup.commandPlaceholder": "ej.: bun install", "settings.openchamber.worktrees.setup.removeCommandAria": "Eliminar comando", "settings.openchamber.worktrees.setup.addCommand": "Añadir comando", + "settings.openchamber.worktrees.setup.waitForCommands": "Esperar a que terminen los comandos de configuración antes de crear o enviar una sesión", + "settings.openchamber.worktrees.setup.waitForCommandsAria": "Esperar los comandos de configuración de Worktree antes de crear o enviar una sesión", "settings.openchamber.worktrees.list.title": "Worktrees existentes", "settings.openchamber.worktrees.list.tooltip": "Los worktrees existen fuera del repositorio (gestionados por OpenCode). Eliminar un worktree también elimina las sesiones vinculadas.", "settings.openchamber.worktrees.list.loading": "Cargando worktrees...", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 51a60d62..9cb6220a 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1080,6 +1080,8 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.commandPlaceholder': 'p. ex. bun install', 'settings.openchamber.worktrees.setup.removeCommandAria': 'Supprimer la commande', 'settings.openchamber.worktrees.setup.addCommand': 'Ajouter une commande', + 'settings.openchamber.worktrees.setup.waitForCommands': 'Attendre la fin des commandes de configuration avant de créer ou d\'envoyer une session', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Attendre les commandes de configuration Worktree avant de créer ou d\'envoyer une session', 'settings.openchamber.worktrees.list.title': 'Worktrees existants', 'settings.openchamber.worktrees.list.tooltip': 'Les worktrees vivent en dehors du dépôt (gérés par OpenCode). La suppression d\'un worktree supprime également les sessions liées.', 'settings.openchamber.worktrees.list.loading': 'Chargement des worktrees...', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index aca29809..8c9162f1 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1091,6 +1091,8 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.commandPlaceholder': '예: bun install', 'settings.openchamber.worktrees.setup.removeCommandAria': '명령어 제거', 'settings.openchamber.worktrees.setup.addCommand': '명령어 추가', + 'settings.openchamber.worktrees.setup.waitForCommands': '세션을 만들거나 보내기 전에 설정 명령이 끝날 때까지 기다리기', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': '세션을 만들거나 보내기 전에 Worktree 설정 명령이 끝날 때까지 기다리기', 'settings.openchamber.worktrees.list.title': '기존 worktree', 'settings.openchamber.worktrees.list.tooltip': 'Worktree는 repo 외부에 있습니다(OpenCode 관리). worktree를 삭제하면 연결된 세션도 제거됩니다.', 'settings.openchamber.worktrees.list.loading': 'worktree 로딩 중...', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 03600ead..5d305ec3 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1070,6 +1070,8 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.loading': 'Ładowanie...', 'settings.openchamber.worktrees.setup.removeCommandAria': 'Usuń polecenie', 'settings.openchamber.worktrees.setup.title': 'Polecenia konfiguracji', + 'settings.openchamber.worktrees.setup.waitForCommands': 'Czekaj na zakończenie poleceń konfiguracji przed utworzeniem lub wysłaniem sesji', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Czekaj na polecenia konfiguracji Worktree przed utworzeniem lub wysłaniem sesji', 'settings.openchamber.worktrees.setup.tooltipPrefix': 'Uruchamiaj automatycznie w nowym katalogu worktree po jego utworzeniu. Użyj', 'settings.openchamber.worktrees.setup.tooltipSuffix': 'dla katalogu głównego projektu.', 'settings.openchamber.worktrees.state.gitOnly': 'Ustawienia worktree są dostępne tylko dla repozytoriów Git.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 98daddb1..60738525 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1091,6 +1091,8 @@ export const settingsDict = { "settings.openchamber.worktrees.setup.commandPlaceholder": "ej.: bun install", "settings.openchamber.worktrees.setup.removeCommandAria": "Excluir comando", "settings.openchamber.worktrees.setup.addCommand": "Adicionar comando", + "settings.openchamber.worktrees.setup.waitForCommands": "Aguardar os comandos de configuração antes de criar ou enviar uma sessão", + "settings.openchamber.worktrees.setup.waitForCommandsAria": "Aguardar os comandos de configuração do Worktree antes de criar ou enviar uma sessão", "settings.openchamber.worktrees.list.title": "Worktrees existentes", "settings.openchamber.worktrees.list.tooltip": "Os worktrees existem fuera do repositório (gerenciados por OpenCode). Excluir um worktree também remove sessões vinculadas.", "settings.openchamber.worktrees.list.loading": "Carregando worktrees...", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index a796c7ca..8d1dcef1 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1091,6 +1091,8 @@ export const settingsDict = { "settings.openchamber.worktrees.setup.commandPlaceholder": "наприклад, bun install", "settings.openchamber.worktrees.setup.removeCommandAria": "Видалити команду", "settings.openchamber.worktrees.setup.addCommand": "Додати команду", + "settings.openchamber.worktrees.setup.waitForCommands": "Чекати завершення команд налаштування перед створенням або надсиланням сесії", + "settings.openchamber.worktrees.setup.waitForCommandsAria": "Чекати завершення команд налаштування Worktree перед створенням або надсиланням сесії", "settings.openchamber.worktrees.list.title": "Наявні worktree", "settings.openchamber.worktrees.list.tooltip": "Worktree розташовані поза репозиторієм і керуються OpenCode. Видалення worktree також видаляє пов’язані сесії.", "settings.openchamber.worktrees.list.loading": "Завантаження worktree...", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 9982c676..9e2b9ccf 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1091,6 +1091,8 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.commandPlaceholder': '例如:bun install', 'settings.openchamber.worktrees.setup.removeCommandAria': '移除命令', 'settings.openchamber.worktrees.setup.addCommand': '添加命令', + 'settings.openchamber.worktrees.setup.waitForCommands': '创建或发送会话前等待初始化命令完成', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': '创建或发送会话前等待 Worktree 初始化命令完成', 'settings.openchamber.worktrees.list.title': '现有工作树', 'settings.openchamber.worktrees.list.tooltip': '工作树位于仓库外部(由 OpenCode 管理)。删除工作树也会删除关联会话。', 'settings.openchamber.worktrees.list.loading': '正在加载工作树...', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 7a9439db..d5518674 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1012,6 +1012,8 @@ 'settings.openchamber.worktrees.setup.commandPlaceholder': '例如:bun install', 'settings.openchamber.worktrees.setup.removeCommandAria': '移除命令', 'settings.openchamber.worktrees.setup.addCommand': '新增命令', + 'settings.openchamber.worktrees.setup.waitForCommands': '建立或傳送工作階段前等待設定命令完成', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': '建立或傳送工作階段前等待 Worktree 設定命令完成', 'settings.openchamber.worktrees.list.title': '現有 worktree', 'settings.openchamber.worktrees.list.tooltip': 'Worktree 位於儲存庫外部(由 OpenCode 管理)。刪除 worktree 也會刪除關聯工作階段。', 'settings.openchamber.worktrees.list.loading': '正在載入 worktree...', diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index 264cc5b0..e80fa289 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -33,6 +33,7 @@ function getRuntimeFilesAPI(): FilesAPI | null { export interface OpenChamberConfig { projectPath?: string; 'setup-worktree'?: string[]; + 'setup-worktree-wait'?: boolean; projectNotes?: string; projectTodos?: OpenChamberProjectTodoItem[]; projectPlanFiles?: OpenChamberProjectPlanFileLink[]; @@ -699,6 +700,15 @@ export async function saveWorktreeSetupCommands(project: ProjectRef, commands: s return updateOpenChamberConfig(project, { 'setup-worktree': filtered }); } +export async function getWorktreeSetupWaitEnabled(project: ProjectRef): Promise { + const config = await readOpenChamberConfig(project); + return config?.['setup-worktree-wait'] === true; +} + +export async function saveWorktreeSetupWaitEnabled(project: ProjectRef, enabled: boolean): Promise { + return updateOpenChamberConfig(project, { 'setup-worktree-wait': enabled }); +} + /** * Get this project's pinned draft welcome starters. */ diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 8a849308..9c9f1c89 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -373,6 +373,12 @@ export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.projects.page.section.worktree', keywords: ['worktree', 'branch', 'repository'], }, + { + id: 'projects.worktree.setup.wait', + page: 'projects', + titleKey: 'settings.openchamber.worktrees.setup.waitForCommands', + keywords: ['worktree', 'setup commands', 'bootstrap', 'wait'], + }, { id: 'remote-instances.client-auth', page: 'remote-instances', diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index 1e152559..1cc475e7 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -14,7 +14,7 @@ import { checkIsGitRepository, previewGitWorktree } from '@/lib/gitApi'; import { generateBranchName } from '@/lib/git/branchNameGenerator'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; -import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; +import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig'; import { removeProjectWorktree, type ProjectRef, @@ -25,9 +25,16 @@ import { rejectPendingDraftWorktreeRequest, resolvePendingDraftWorktreeRequest, } from '@/lib/worktrees/pendingDraftWorktree'; +import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value; +const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory: string): Promise => { + if (await getWorktreeSetupWaitEnabled(project)) { + await waitForWorktreeBootstrap(directory); + } +}; + const resolveProjectRef = (directory: string): ProjectRef | null => { const normalized = normalizePath(directory); const projects = useProjectsStore.getState().projects; @@ -417,6 +424,8 @@ export async function createWorktreeSessionForBranch( kind, }; + await waitForWorktreeBootstrapIfEnabled(projectRef, metadata.path); + // Create the session const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); @@ -520,6 +529,8 @@ export async function createWorktreeSessionForNewBranch( kind, }; + await waitForWorktreeBootstrapIfEnabled(projectRef, metadata.path); + const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { diff --git a/packages/ui/src/stores/useMultiRunStore.test.ts b/packages/ui/src/stores/useMultiRunStore.test.ts index 6af180c6..b2a1bbb6 100644 --- a/packages/ui/src/stores/useMultiRunStore.test.ts +++ b/packages/ui/src/stores/useMultiRunStore.test.ts @@ -6,7 +6,10 @@ const registeredDirectories: Array<{ sessionID: string; directory: string }> = [ const ensureChildCalls: Array<{ directory: string; bootstrap?: boolean }> = []; const worktreeMetadataCalls: Array<{ sessionId: string; path: string }> = []; const worktreeCreateCalls: Array<{ project: { id?: string; path: string }; args: Record; options: unknown }> = []; +const worktreeBootstrapWaitCalls: string[] = []; +const operationOrder: string[] = []; let isGitRepository = false; +let waitForWorktreeSetup = false; const createWorktreeWithDefaultsMock = mock((project: { id?: string; path: string }, args: Record, options: unknown) => { worktreeCreateCalls.push({ project, args, options }); return Promise.resolve({ @@ -52,12 +55,15 @@ mock.module('@/lib/opencode/client', () => ({ currentDirectory = previous; } }, - createSession: async (params?: { title?: string }): Promise => ({ - id: 'ses_multirun', - title: params?.title ?? '', - directory: currentDirectory, - time: { created: 1, updated: 1 }, - } as Session), + createSession: async (params?: { title?: string }): Promise => { + operationOrder.push(`createSession:${currentDirectory}`); + return { + id: 'ses_multirun', + title: params?.title ?? '', + directory: currentDirectory, + time: { created: 1, updated: 1 }, + } as Session; + }, }, })); @@ -70,11 +76,20 @@ mock.module('@/lib/worktrees/worktreeCreate', () => ({ resolveRootTrackingRemote: mock(() => Promise.resolve(null)), })); +mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ + waitForWorktreeBootstrap: (directory: string) => { + worktreeBootstrapWaitCalls.push(directory); + operationOrder.push(`wait:${directory}`); + return Promise.resolve(); + }, +})); + mock.module('@/lib/worktrees/worktreeStatus', () => ({ getRootBranch: mock(() => Promise.resolve('main')), })); mock.module('@/lib/openchamberConfig', () => ({ + getWorktreeSetupWaitEnabled: mock(() => Promise.resolve(waitForWorktreeSetup)), saveWorktreeSetupCommands: mock(() => Promise.resolve()), })); @@ -139,7 +154,10 @@ describe('useMultiRunStore', () => { ensureChildCalls.length = 0; worktreeMetadataCalls.length = 0; worktreeCreateCalls.length = 0; + worktreeBootstrapWaitCalls.length = 0; + operationOrder.length = 0; isGitRepository = false; + waitForWorktreeSetup = false; childState.session = []; childState.sessionTotal = 0; childState.limit = 5; @@ -181,7 +199,30 @@ describe('useMultiRunStore', () => { expect(worktreeCreateCalls[0]?.project).toEqual({ id: 'project-1', path: '/repo' }); expect(worktreeCreateCalls[0]?.args.returnAfterDirectoryCreated).toBe(true); expect(worktreeCreateCalls[0]?.options).toEqual({ resolvedRootTrackingRemote: null }); + expect(worktreeBootstrapWaitCalls).toEqual([]); + expect(operationOrder).toEqual(['createSession:/repo-worktrees/fix-thing']); expect(registeredDirectories).toEqual([{ sessionID: 'ses_multirun', directory: '/repo-worktrees/fix-thing' }]); expect(worktreeMetadataCalls).toEqual([{ sessionId: 'ses_multirun', path: '/repo-worktrees/fix-thing' }]); }); + + test('waits for isolated worktree bootstrap when setup wait is enabled', async () => { + isGitRepository = true; + waitForWorktreeSetup = true; + + const result = await useMultiRunStore.getState().createMultiRun({ + name: 'Fix thing', + isolateRuns: true, + groups: [{ + prompt: 'Fix it', + models: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }], + }], + }); + + expect(result?.sessionIds).toEqual(['ses_multirun']); + expect(worktreeBootstrapWaitCalls).toEqual(['/repo-worktrees/fix-thing']); + expect(operationOrder).toEqual([ + 'wait:/repo-worktrees/fix-thing', + 'createSession:/repo-worktrees/fix-thing', + ]); + }); }); diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 310e29b9..c06d9605 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -4,9 +4,10 @@ import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store'; import { devtools } from 'zustand/middleware'; import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun'; import { opencodeClient } from '@/lib/opencode/client'; -import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; +import { getWorktreeSetupWaitEnabled, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; +import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { checkIsGitRepository } from '@/lib/gitApi'; import { useDirectoryStore } from './useDirectoryStore'; @@ -244,6 +245,10 @@ export const useMultiRunStore = create()( kind: 'standard' as const, }; + if (await getWorktreeSetupWaitEnabled(project)) { + await waitForWorktreeBootstrap(worktreeMetadata.path); + } + const session = await opencodeClient.withDirectory( worktreeMetadata.path, () => opencodeClient.createSession({ title: sessionTitle }), diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 88d0b7f3..f32c837e 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -31,6 +31,7 @@ import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" +import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig" import { resolveProjectForSessionDirectory } from "@/lib/projectResolution" import { getSyncSessions, @@ -416,6 +417,24 @@ type MaterializedDraftSession = { syntheticParts?: SyntheticContextPart[] } +const resolveProjectRefForWorktreeDirectory = (directory: string | null, projectId?: string | null): { id: string; path: string } | null => { + const projectsState = useProjectsStore.getState() + if (projectId) { + const project = projectsState.projects.find((entry) => entry.id === projectId) + if (project?.path) return { id: project.id, path: project.path } + } + const resolved = resolveProjectForSessionDirectory(projectsState.projects, useSessionUIStore.getState().availableWorktreesByProject, directory) + return resolved?.path ? { id: resolved.id, path: resolved.path } : null +} + +const waitForWorktreeBootstrapIfConfigured = async (directory: string | null, projectId?: string | null): Promise => { + if (!directory) return + const project = resolveProjectRefForWorktreeDirectory(directory, projectId) + if (project && await getWorktreeSetupWaitEnabled(project)) { + await waitForWorktreeBootstrap(directory) + } +} + export async function materializeOpenDraftSession(selection: { providerID: string modelID: string @@ -437,9 +456,7 @@ export async function materializeOpenDraftSession(selection: { store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) } - if (draftDirectoryOverride) { - await waitForWorktreeBootstrap(draftDirectoryOverride) - } + await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId) const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) if (!created?.id) throw new Error("Failed to create session") @@ -1371,6 +1388,12 @@ export const useSessionUIStore = create()((set, get) => ({ returnAfterDirectoryCreated: true, }) sessionDirectory = normalizePath(createdWorktree.path) + if (!sessionDirectory) { + throw new Error("Worktree create missing name/path") + } + if (await configModule.getWorktreeSetupWaitEnabled(createdWorktreeProject)) { + await waitForWorktreeBootstrap(sessionDirectory) + } } const session = await get().createSession(undefined, sessionDirectory || null, null) diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 28ff3cb0..b1bb6e4d 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -1754,6 +1754,19 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit } } +const assertWorktreeCreatePreflight = async (directory: string, input: CreateGitWorktreePayload = {}): Promise => { + const validation = await validateWorktreeCreate(directory, input); + if (validation?.ok) { + return; + } + + const message = validation?.errors + ?.map((error) => error?.message) + .filter(Boolean) + .join('\n') || 'Failed to validate worktree creation'; + throw new Error(message); +}; + export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -1907,6 +1920,11 @@ async function attachGitWorktreeToCandidate( export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); + + if (input?.returnAfterDirectoryCreated === true) { + await assertWorktreeCreatePreflight(directory, input); + } + await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); const preferredName = String(input?.worktreeName || input?.name || '').trim(); diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index b4c23b9f..007b4671 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3550,6 +3550,19 @@ export async function validateWorktreeCreate(directory, input = {}) { } } +const assertWorktreeCreatePreflight = async (directory, input = {}) => { + const validation = await validateWorktreeCreate(directory, input); + if (validation?.ok) { + return; + } + + const message = validation?.errors + ?.map((error) => error?.message) + .filter(Boolean) + .join('\n') || 'Failed to validate worktree creation'; + throw new Error(message); +}; + export async function previewWorktreeCreate(directory, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -3698,6 +3711,11 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { export async function createWorktree(directory, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); + + if (input?.returnAfterDirectoryCreated === true) { + await assertWorktreeCreatePreflight(directory, input); + } + await fsp.mkdir(context.worktreeRoot, { recursive: true }); const preferredName = String(input?.worktreeName || input?.name || '').trim(); diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index fb67b276..bb839ac0 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -8,6 +8,7 @@ import simpleGit from 'simple-git'; import { checkoutCommit, cherryPick, + createWorktree, getStatus, removeWorktree, resolvePrimaryWorktreeRoot, @@ -315,6 +316,53 @@ describe('worktree root resolution', () => { }); }); +// --------------------------------------------------------------------------- +// createWorktree +// --------------------------------------------------------------------------- + +describe('createWorktree', () => { + it('preflights fast create branch-in-use failures before creating the candidate directory', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + const worktree = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + const projectID = runGit(repo, ['rev-list', '--max-parents=0', '--all']).trim(); + + fs.rmSync(worktree, { recursive: true, force: true }); + runGit(repo, ['worktree', 'add', '-b', 'feature/in-use', worktree, 'HEAD']); + const canonicalWorktree = fs.realpathSync(worktree); + + await expect(createWorktree(repo, { + mode: 'existing', + existingBranch: 'feature/in-use', + branchName: 'feature/in-use', + worktreeName: 'feature-in-use', + returnAfterDirectoryCreated: true, + })).rejects.toThrow(`Branch is already checked out in ${canonicalWorktree}`); + + const candidateDirectory = path.join(dataHome, 'opencode', 'worktree', projectID, 'feature-in-use'); + expect(fs.existsSync(candidateDirectory)).toBe(false); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); +}); + // --------------------------------------------------------------------------- // removeWorktree // ---------------------------------------------------------------------------