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:
committed by
GitHub
co-authored by
Leonid Skorobogatyy
Bohdan Triapitsyn
parent
03e6f789a4
commit
8c1a24089d
@@ -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...',
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user