fix(worktree): gate sessions on bootstrap readiness (#1762)

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
bashrusakh
2026-06-26 12:16:42 +03:00
committed by GitHub
co-authored by Leonid Skorobogatyy Bohdan Triapitsyn
parent 03e6f789a4
commit 8c1a24089d
20 changed files with 252 additions and 14 deletions
@@ -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<WorktreeSectionContentProps> = ({
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
const [waitForSetupCommands, setWaitForSetupCommands] = React.useState(false);
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
const [isGitRepoLocal, setIsGitRepoLocal] = React.useState<boolean | null>(null);
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
@@ -127,13 +134,18 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
(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<WorktreeSectionContentProps> = ({
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<WorktreeSectionContentProps> = ({
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.openchamber.worktrees.setup.addCommand')}
</Button>
<label
data-settings-item="projects.worktree.setup.wait"
className="flex cursor-pointer items-center gap-2 py-1.5"
>
<Checkbox
checked={waitForSetupCommands}
onChange={handleWaitForSetupCommandsChange}
ariaLabel={t('settings.openchamber.worktrees.setup.waitForCommandsAria')}
/>
<span className={cn(
'typography-ui-label font-normal',
waitForSetupCommands ? 'text-foreground' : 'text-foreground/60'
)}>
{t('settings.openchamber.worktrees.setup.waitForCommands')}
</span>
</label>
</div>
)}
</div>
@@ -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
@@ -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...',
@@ -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...",
@@ -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...',
@@ -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 로딩 중...',
@@ -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.',
@@ -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...",
@@ -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...",
@@ -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': '正在加载工作树...',
@@ -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...',
+10
View File
@@ -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<boolean> {
const config = await readOpenChamberConfig(project);
return config?.['setup-worktree-wait'] === true;
}
export async function saveWorktreeSetupWaitEnabled(project: ProjectRef, enabled: boolean): Promise<boolean> {
return updateOpenChamberConfig(project, { 'setup-worktree-wait': enabled });
}
/**
* Get this project's pinned draft welcome starters.
*/
+6
View File
@@ -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',
+12 -1
View File
@@ -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<void> => {
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) {
@@ -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<string, unknown>; options: unknown }> = [];
const worktreeBootstrapWaitCalls: string[] = [];
const operationOrder: string[] = [];
let isGitRepository = false;
let waitForWorktreeSetup = false;
const createWorktreeWithDefaultsMock = mock((project: { id?: string; path: string }, args: Record<string, unknown>, 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<Session> => ({
id: 'ses_multirun',
title: params?.title ?? '',
directory: currentDirectory,
time: { created: 1, updated: 1 },
} as Session),
createSession: async (params?: { title?: string }): Promise<Session> => {
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',
]);
});
});
+6 -1
View File
@@ -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<MultiRunStore>()(
kind: 'standard' as const,
};
if (await getWorktreeSetupWaitEnabled(project)) {
await waitForWorktreeBootstrap(worktreeMetadata.path);
}
const session = await opencodeClient.withDirectory(
worktreeMetadata.path,
() => opencodeClient.createSession({ title: sessionTitle }),
+26 -3
View File
@@ -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<void> => {
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<SessionUIState>()((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)
+18
View File
@@ -1754,6 +1754,19 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
}
}
const assertWorktreeCreatePreflight = async (directory: string, input: CreateGitWorktreePayload = {}): Promise<void> => {
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<GitWorktreeInfo> {
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<GitWorktreeInfo> {
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();
+18
View File
@@ -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();
@@ -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
// ---------------------------------------------------------------------------