feat: move sessions to new worktrees

Add a root-session action that creates a generated worktree from the session directory's current branch, transfers uncommitted changes, and moves the parent session plus its descendants through OpenCode's control-plane API.

Reuse existing project/worktree topology and quick-create behavior, keep the UI non-blocking, reconcile live and global session state across directories, and roll back partial moves and failed worktree creation safely.

Split worktree bootstrap readiness into directory-created, git-ready, and setup-ready phases across web and VS Code. Session moves wait for Git readiness while existing setup-aware flows continue waiting for full setup completion, and worktree removal is serialized with active bootstrap tasks.

Expose the move only for idle root sessions, show localized progress and explanatory tooltips in the sidebar, and keep pending/ready worktree metadata synchronized with authoritative session attachments to avoid stale setup indicators.

Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
This commit is contained in:
Bohdan Triapitsyn
2026-07-19 00:00:31 +03:00
parent e9d93a6744
commit 3fd6627196
31 changed files with 1322 additions and 151 deletions
+1
View File
@@ -357,6 +357,7 @@ export interface GitWorktreeValidationResult {
export interface GitWorktreeBootstrapStatus {
status: 'pending' | 'ready' | 'failed';
phase?: 'directory-created' | 'git-ready' | 'setup-ready';
error: string | null;
updatedAt: number;
}
+7
View File
@@ -456,6 +456,12 @@ export const dict = {
'sessions.sidebar.session.menu.copyLink': 'Copy link',
'sessions.sidebar.session.menu.unshare': 'Unshare',
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Move to new worktree',
'sessions.sidebar.session.moveToWorktree.success': 'Session moved to a new worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Failed to move session to a new worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch, transfers uncommitted changes, and moves this session and its sub-sessions there.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Available when the session is idle. Stop or wait for the current activity to finish.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'This session is already being moved to a new worktree.',
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor',
@@ -476,6 +482,7 @@ export const dict = {
'sessions.sidebar.session.status.active': 'Session active',
'sessions.sidebar.session.status.unread': 'Unread updates',
'sessions.sidebar.session.status.pinned': 'Pinned session',
'sessions.sidebar.session.status.movingToWorktree': 'Moving session to a new worktree',
'sessions.sidebar.session.status.permissionRequired': 'Permission required',
'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions',
'sessions.sidebar.session.subsessions.expand': 'Expand subsessions',
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.copyLink": "Copiar enlace",
"sessions.sidebar.session.menu.unshare": "Dejar de compartir",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover a un worktree nuevo",
"sessions.sidebar.session.moveToWorktree.success": "Sesión movida a un worktree nuevo",
"sessions.sidebar.session.moveToWorktree.failed": "No se pudo mover la sesión a un worktree nuevo",
"sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual, transfiere los cambios sin confirmar y mueve allí esta sesión y sus subsesiones.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponible cuando la sesión está inactiva. Detén la actividad actual o espera a que termine.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sesión ya se está moviendo a un worktree nuevo.",
"sessions.sidebar.session.menu.runFusion": "Ejecutar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir en el editor",
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.active": "Sesión activa",
"sessions.sidebar.session.status.unread": "Actualizaciones no leídas",
"sessions.sidebar.session.status.pinned": "Sesión anclada",
"sessions.sidebar.session.status.movingToWorktree": "Moviendo la sesión a un worktree nuevo",
"sessions.sidebar.session.status.permissionRequired": "Permiso requerido",
"sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones",
"sessions.sidebar.session.subsessions.expand": "Expandir subsesiones",
+7
View File
@@ -298,6 +298,12 @@ export const dict = {
'sessions.sidebar.session.menu.copyLink': 'Copier le lien',
'sessions.sidebar.session.menu.unshare': 'Annuler le partage',
'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Déplacer vers un nouveau worktree',
'sessions.sidebar.session.moveToWorktree.success': 'Session déplacée vers un nouveau worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Impossible de déplacer la session vers un nouveau worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle, transfère les modifications non validées et y déplace cette session et ses sous-sessions.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Disponible lorsque la session est inactive. Arrêtez l’activité en cours ou attendez sa fin.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Cette session est déjà en cours de déplacement vers un nouveau worktree.',
'sessions.sidebar.session.menu.runFusion': 'Exécuter la fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral',
'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur',
@@ -318,6 +324,7 @@ export const dict = {
'sessions.sidebar.session.status.active': 'Session active',
'sessions.sidebar.session.status.unread': 'Mises à jour non lues',
'sessions.sidebar.session.status.pinned': 'Session épinglée',
'sessions.sidebar.session.status.movingToWorktree': 'Déplacement de la session vers un nouveau worktree',
'sessions.sidebar.session.status.permissionRequired': 'Autorisation requise',
'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions',
'sessions.sidebar.session.subsessions.expand': 'Développer les sous-sessions',
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': 'リンクをコピー',
'sessions.sidebar.session.menu.unshare': '共有解除',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート',
'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動',
'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました',
'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした',
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。',
'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行',
'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く',
'sessions.sidebar.session.actions.openInEditor': 'エディターで開く',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': 'セッションアクティブ',
'sessions.sidebar.session.status.unread': '未読の更新',
'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション',
'sessions.sidebar.session.status.movingToWorktree': 'セッションを新しいworktreeへ移動中',
'sessions.sidebar.session.status.permissionRequired': '権限が必要です',
'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ',
'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開',
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': '링크 복사',
'sessions.sidebar.session.menu.unshare': '공유 해제',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동',
'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다',
'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다',
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.',
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': '활성 세션',
'sessions.sidebar.session.status.unread': '읽지 않은 업데이트',
'sessions.sidebar.session.status.pinned': '고정된 세션',
'sessions.sidebar.session.status.movingToWorktree': '세션을 새 worktree로 이동하는 중',
'sessions.sidebar.session.status.permissionRequired': '권한 필요',
'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기',
'sessions.sidebar.session.subsessions.expand': '하위 세션 펼치기',
+7
View File
@@ -262,6 +262,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': 'Kopiuj link',
'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie',
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Przenieś do nowego worktree',
'sessions.sidebar.session.moveToWorktree.success': 'Sesja została przeniesiona do nowego worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Nie udało się przenieść sesji do nowego worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi, przenosi niezacommitowane zmiany oraz tę sesję i jej podsesje.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Dostępne, gdy sesja jest bezczynna. Zatrzymaj bieżącą aktywność lub poczekaj na jej zakończenie.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Ta sesja jest już przenoszona do nowego worktree.',
'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': 'Sesja aktywna',
'sessions.sidebar.session.status.unread': 'Nieprzeczytane aktualizacje',
'sessions.sidebar.session.status.pinned': 'Przypięta sesja',
'sessions.sidebar.session.status.movingToWorktree': 'Przenoszenie sesji do nowego worktree',
'sessions.sidebar.session.status.permissionRequired': 'Wymagane uprawnienie',
'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje',
'sessions.sidebar.session.subsessions.expand': 'Rozwiń pod-sesje',
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.copyLink": "Copiar link",
"sessions.sidebar.session.menu.unshare": "Parar de compartilhar",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover para um novo worktree",
"sessions.sidebar.session.moveToWorktree.success": "Sessão movida para um novo worktree",
"sessions.sidebar.session.moveToWorktree.failed": "Não foi possível mover a sessão para um novo worktree",
"sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual, transfere alterações não commitadas e move esta sessão e suas subsessões para lá.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponível quando a sessão está ociosa. Interrompa a atividade atual ou aguarde sua conclusão.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sessão já está sendo movida para um novo worktree.",
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.active": "Sessão ativa",
"sessions.sidebar.session.status.unread": "Atualizações não lidas",
"sessions.sidebar.session.status.pinned": "Sessão fixada",
"sessions.sidebar.session.status.movingToWorktree": "Movendo a sessão para um novo worktree",
"sessions.sidebar.session.status.permissionRequired": "Permissão obrigatória",
"sessions.sidebar.session.subsessions.collapse": "Recolher subsessões",
"sessions.sidebar.session.subsessions.expand": "Expandir subsessões",
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.copyLink": "Копіювати посилання",
"sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ",
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree",
"sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree",
"sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree",
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.",
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.active": "Сесія активний",
"sessions.sidebar.session.status.unread": "Непрочитані оновлення",
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': '复制链接',
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树',
'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树',
'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树',
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。',
'sessions.sidebar.session.menu.runFusion': '运行融合',
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': '会话活跃中',
'sessions.sidebar.session.status.unread': '有未读更新',
'sessions.sidebar.session.status.pinned': '已置顶会话',
'sessions.sidebar.session.status.movingToWorktree': '正在将会话移至新工作树',
'sessions.sidebar.session.status.permissionRequired': '需要权限',
'sessions.sidebar.session.subsessions.collapse': '折叠子会话',
'sessions.sidebar.session.subsessions.expand': '展开子会话',
@@ -470,6 +470,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': '複製連結',
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹',
'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹',
'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹',
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。',
'sessions.sidebar.session.menu.runFusion': '執行 fusion',
'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟',
'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟',
@@ -490,6 +496,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': '會話活躍中',
'sessions.sidebar.session.status.unread': '有未讀更新',
'sessions.sidebar.session.status.pinned': '已釘選會話',
'sessions.sidebar.session.status.movingToWorktree': '正在將會話移至新工作樹',
'sessions.sidebar.session.status.permissionRequired': '需要權限',
'sessions.sidebar.session.subsessions.collapse': '摺疊子會話',
'sessions.sidebar.session.subsessions.expand': '展開子會話',
+42 -28
View File
@@ -26,8 +26,8 @@ import {
resolvePendingDraftWorktreeRequest,
} from '@/lib/worktrees/pendingDraftWorktree';
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
import { normalizePath } from '@/lib/pathNormalization';
import { resolveProjectForDirectory } from '@/lib/projectResolution';
const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory: string): Promise<void> => {
if (await getWorktreeSetupWaitEnabled(project)) {
@@ -35,29 +35,51 @@ const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory:
}
};
const resolveProjectRef = (directory: string): ProjectRef | null => {
const normalized = normalizePath(directory);
export const resolveProjectRef = (directory: string): ProjectRef | null => {
const projects = useProjectsStore.getState().projects;
if (projects.length === 0) {
return null;
}
const normalizedDirectory = normalizePath(directory);
if (!normalizedDirectory) return null;
const activeProject = useProjectsStore.getState().getActiveProject();
if (activeProject?.path) {
const activePath = normalizePath(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
let project: (typeof projects)[number] | null = null;
let matchedWorktreePathLength = -1;
for (const [projectPath, worktrees] of useSessionUIStore.getState().availableWorktreesByProject) {
for (const worktree of worktrees) {
const worktreePath = normalizePath(worktree.path);
if (!worktreePath) continue;
if (normalizedDirectory !== worktreePath && !normalizedDirectory.startsWith(`${worktreePath}/`)) continue;
if (worktreePath.length <= matchedWorktreePathLength) continue;
const ownerPaths = [worktree.projectDirectory, projectPath];
for (const ownerPath of ownerPaths) {
const owner = projects.find((candidate) => normalizePath(candidate.path) === normalizePath(ownerPath))
?? resolveProjectForDirectory(projects, ownerPath);
if (!owner) continue;
project = owner;
matchedWorktreePathLength = worktreePath.length;
break;
}
}
}
const matches = projects.filter((project) => {
const projectPath = normalizePath(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
project ??= resolveProjectForDirectory(projects, normalizedDirectory);
return project ? { id: project.id, path: project.path } : null;
};
export const createQuickWorktree = async (
project: ProjectRef,
options: { preferredName?: string; startRef?: string } = {},
) => {
const preferredName = options.preferredName ?? generateBranchName();
const setupCommands = await getWorktreeSetupCommands(project);
return createWorktreeWithDefaults(project, {
preferredName,
mode: 'new',
branchName: preferredName,
worktreeName: preferredName,
startRef: options.startRef,
setupCommands,
returnAfterDirectoryCreated: true,
});
const match = matches.sort((a, b) => normalizePath(b.path).length - normalizePath(a.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
// Track if a worktree creation flow is already running
@@ -233,15 +255,7 @@ const createInstantWorktreeDraft = async (options?: {
useDirectoryStore.getState().setDirectory(preview.path, { showOverlay: false });
}
const setupCommands = await getWorktreeSetupCommands(projectRef);
const metadata = await createWorktreeWithDefaults(projectRef, {
preferredName,
mode: 'new',
branchName: preferredName,
worktreeName: preferredName,
setupCommands,
returnAfterDirectoryCreated: true,
});
const metadata = await createQuickWorktree(projectRef, { preferredName });
resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path);
useSessionUIStore.getState().overrideNewSessionDraftTarget({
@@ -0,0 +1,180 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { getGitStatus } from '@/lib/gitApi';
import { normalizePath } from '@/lib/pathNormalization';
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
import { refreshGlobalSessionsForDirectories } from '@/stores/useGlobalSessionsStore';
import { moveSessionToDirectory } from '@/sync/session-actions';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getDirectoryState } from '@/sync/sync-refs';
import type { WorktreeMetadata } from '@/types/worktree';
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
import { create } from 'zustand';
const useSessionMoveState = create<{ pendingSessionIds: Set<string> }>(() => ({
pendingSessionIds: new Set(),
}));
export const useIsSessionWorktreeMovePending = (sessionId: string): boolean =>
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId));
const setSessionMovePending = (sessionId: string, pending: boolean): void => {
useSessionMoveState.setState((state) => {
if (state.pendingSessionIds.has(sessionId) === pending) return state;
const pendingSessionIds = new Set(state.pendingSessionIds);
if (pending) pendingSessionIds.add(sessionId);
else pendingSessionIds.delete(sessionId);
return { pendingSessionIds };
});
};
const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise<string> => {
try {
const status = await getGitStatus(directory, { mode: 'light' });
const currentBranch = status.current?.trim();
if (currentBranch) return currentBranch;
} catch {
// Fall back to discovered worktree metadata below.
}
const normalizedDirectory = normalizePath(directory);
const normalizedProjectDirectory = normalizePath(projectDirectory) ?? projectDirectory;
const worktrees = useSessionUIStore.getState().availableWorktreesByProject;
const metadata = (worktrees.get(normalizedProjectDirectory) ?? worktrees.get(projectDirectory) ?? [])
.find((worktree) => normalizePath(worktree.path) === normalizedDirectory);
const mappedBranch = metadata?.branch?.trim();
if (mappedBranch) return mappedBranch;
throw new Error('Unable to determine the current branch');
};
const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void => {
const directoryState = getDirectoryState(sourceDirectory);
if (!directoryState) throw new Error('Session status is unavailable');
const statuses = directoryState.session_status;
const hasActiveSession = sessions.some((session) => {
const status = statuses[session.id]?.type;
return status === 'busy' || status === 'retry';
});
if (hasActiveSession) throw new Error('Session is not idle');
};
const rollbackMovedSessions = async (
sessions: Session[],
rootSessionId: string,
sourceDirectory: string,
worktreeDirectory: string,
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
): Promise<unknown[]> => {
const failures: unknown[] = [];
for (const session of [...sessions].reverse()) {
try {
await moveSessionToDirectory(
session,
worktreeDirectory,
sourceDirectory,
session.id === rootSessionId,
);
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
} catch (error) {
failures.push(error);
}
}
return failures;
};
const removeFailedWorktree = async (
project: ProjectRef,
worktree: WorktreeMetadata,
moveError: unknown,
): Promise<never> => {
try {
await removeProjectWorktree(project, worktree, { deleteLocalBranch: true });
} catch {
const message = moveError instanceof Error ? moveError.message : String(moveError);
throw new Error(`Session move failed and the new worktree could not be removed: ${message}`);
}
throw moveError;
};
const moveSessionTreeToQuickWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
}): Promise<string> => {
if (useSessionMoveState.getState().pendingSessionIds.has(input.root.id)) {
throw new Error('Session move already in progress');
}
setSessionMovePending(input.root.id, true);
try {
const project = resolveProjectRef(input.sourceDirectory);
if (!project) throw new Error('Unable to find the project for this session');
const sessions = [input.root, ...input.descendants];
const previousMetadata = new Map(
sessions.map((session) => [
session.id,
useSessionUIStore.getState().getWorktreeMetadata(session.id),
]),
);
assertSessionsIdle(sessions, input.sourceDirectory);
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
const moved: Session[] = [];
try {
await waitForWorktreeGitReady(worktree.path);
// Branch/status discovery and worktree creation can take long enough for a
// session to start running, so verify the whole tree again before moving.
assertSessionsIdle(sessions, input.sourceDirectory);
for (const [index, session] of sessions.entries()) {
// Transfer the checkout changes once with the root. Descendants only
// need their execution location updated.
await moveSessionToDirectory(session, input.sourceDirectory, worktree.path, index === 0);
moved.push(session);
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree));
}
} catch (error) {
const rollbackFailures = await rollbackMovedSessions(
moved,
input.root.id,
input.sourceDirectory,
worktree.path,
previousMetadata,
);
if (rollbackFailures.length > 0) {
throw new Error(`Session move partially failed and could not be fully rolled back: ${error instanceof Error ? error.message : String(error)}`);
}
return removeFailedWorktree(project, worktree, error);
}
try {
await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]);
} catch (error) {
// Direct action updates already reconciled both stores. Keep the move
// successful if this best-effort authoritative refresh is unavailable.
console.warn('[session-worktree-move] Failed to refresh moved sessions', error);
}
return worktree.path;
} finally {
setSessionMovePending(input.root.id, false);
}
};
export const startSessionTreeWorktreeMove = (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
successMessage: string;
failureMessage: string;
}): void => {
void moveSessionTreeToQuickWorktree(input)
.then(() => toast.success(input.successMessage))
.catch((error) => toast.error(input.failureMessage, {
description: error instanceof Error ? error.message : String(error),
}));
};
@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
const bootstrapStatusCalls: string[] = [];
let bootstrapStatusResult: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number } = {
let bootstrapStatusResult: GitWorktreeBootstrapStatus = {
status: 'ready',
error: null,
updatedAt: 1,
};
let getBootstrapStatus = (): Promise<GitWorktreeBootstrapStatus> => Promise.resolve(bootstrapStatusResult);
const toastErrors: Array<{ title: string; description?: string }> = [];
mock.module('@/components/ui', () => ({
@@ -29,7 +31,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
worktree: {
bootstrapStatus: (directory: string) => {
bootstrapStatusCalls.push(directory);
return Promise.resolve(bootstrapStatusResult);
return getBootstrapStatus();
},
},
},
@@ -39,7 +41,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
mock.module('@/lib/gitApiHttp', () => ({
getGitWorktreeBootstrapStatus: (directory: string) => {
bootstrapStatusCalls.push(directory);
return Promise.resolve(bootstrapStatusResult);
return getBootstrapStatus();
},
}));
@@ -47,8 +49,10 @@ const {
clearWorktreeBootstrapState,
getWorktreeBootstrapState,
markWorktreeBootstrapPending,
setWorktreeBootstrapState,
startWorktreeBootstrapWatcher,
waitForWorktreeBootstrap,
waitForWorktreeGitReady,
} = await import('./worktreeBootstrap');
const waitFor = async (predicate: () => boolean): Promise<void> => {
@@ -64,6 +68,7 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
bootstrapStatusCalls.length = 0;
toastErrors.length = 0;
bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 };
getBootstrapStatus = () => Promise.resolve(bootstrapStatusResult);
clearWorktreeBootstrapState('/repo');
clearWorktreeBootstrapState('/repo-wt');
});
@@ -82,6 +87,108 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
});
test('git-ready wait does not wait for setup-ready', async () => {
setWorktreeBootstrapState('/repo-wt', {
status: 'pending',
phase: 'git-ready',
error: null,
updatedAt: 1,
});
await waitForWorktreeGitReady('/repo-wt');
expect(bootstrapStatusCalls).toEqual([]);
});
test('git-ready wait remains compatible with ready responses that omit phase', async () => {
markWorktreeBootstrapPending('/repo-wt');
await waitForWorktreeGitReady('/repo-wt');
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
expect(getWorktreeBootstrapState('/repo-wt')).toEqual(bootstrapStatusResult);
});
test('dedupes concurrent waiters for the same phase', async () => {
let resolveStatus!: (status: GitWorktreeBootstrapStatus) => void;
getBootstrapStatus = () => new Promise((resolve) => {
resolveStatus = resolve;
});
markWorktreeBootstrapPending('/repo-wt');
const first = waitForWorktreeGitReady('/repo-wt');
const second = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => bootstrapStatusCalls.length === 1);
resolveStatus({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
await Promise.all([first, second]);
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
});
test('does not let a cleared waiter restore stale state or remove a replacement waiter', async () => {
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
getBootstrapStatus = () => new Promise((resolve) => {
statusResolvers.push(resolve);
});
markWorktreeBootstrapPending('/repo-wt');
const staleWaiter = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => statusResolvers.length === 1);
clearWorktreeBootstrapState('/repo-wt');
markWorktreeBootstrapPending('/repo-wt');
const replacementWaiter = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => statusResolvers.length === 2);
statusResolvers[0]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
await expect(staleWaiter).rejects.toThrow('cancelled');
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('directory-created');
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
await replacementWaiter;
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
expect(bootstrapStatusCalls).toEqual(['/repo-wt', '/repo-wt']);
});
test('does not regress a newer phase when concurrent polls resolve out of order', async () => {
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
getBootstrapStatus = () => new Promise((resolve) => {
statusResolvers.push(resolve);
});
markWorktreeBootstrapPending('/repo-wt');
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
await waitFor(() => statusResolvers.length === 1);
const gitReadyWaiter = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => statusResolvers.length === 2);
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
await gitReadyWaiter;
statusResolvers[0]({ status: 'pending', phase: 'directory-created', error: null, updatedAt: 2 });
await Promise.resolve();
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
clearWorktreeBootstrapState('/repo-wt');
});
test('full bootstrap wait continues through git-ready until setup-ready', async () => {
setWorktreeBootstrapState('/repo-wt', {
status: 'pending',
phase: 'git-ready',
error: null,
updatedAt: 1,
});
bootstrapStatusResult = {
status: 'ready',
phase: 'setup-ready',
error: null,
updatedAt: 2,
};
await waitForWorktreeBootstrap('/repo-wt');
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
});
test('background watcher polls pending worktrees without blocking', async () => {
markWorktreeBootstrapPending('/repo-wt');
const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
@@ -14,10 +14,66 @@ const POLL_INTERVAL_MS = 250;
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
const state = new Map<string, WorktreeBootstrapState>();
type WorktreeBootstrapTarget = 'git-ready' | 'setup-ready';
const waiters = new Map<string, Promise<void>>();
const watchers = new Map<string, { cancelled: boolean; promise: Promise<void> }>();
const lifecycleVersions = new Map<string, number>();
let nextLifecycleVersion = 0;
const watchers = new Map<string, { cancelled: boolean; lifecycleVersion: number }>();
const getKey = (directory: string): string => normalizePath(directory);
const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`;
const startLifecycle = (key: string): void => {
const watcher = watchers.get(key);
if (watcher) {
watcher.cancelled = true;
watchers.delete(key);
}
waiters.delete(getWaiterKey(key, 'git-ready'));
waiters.delete(getWaiterKey(key, 'setup-ready'));
const version = ++nextLifecycleVersion;
lifecycleVersions.set(key, version);
};
const isCurrentLifecycle = (key: string, version: number): boolean => lifecycleVersions.get(key) === version;
const phaseRank = (phase: GitWorktreeBootstrapStatus['phase']): number => {
switch (phase) {
case 'setup-ready':
return 2;
case 'git-ready':
return 1;
case 'directory-created':
default:
return 0;
}
};
const storePolledState = (
key: string,
next: WorktreeBootstrapState,
lifecycleVersion: number,
): WorktreeBootstrapState | null => {
if (!isCurrentLifecycle(key, lifecycleVersion)) {
return null;
}
const current = state.get(key);
const wouldRegressReadyState = current?.status === 'ready' && next.status === 'pending';
const wouldRegressPendingPhase = current?.status === 'pending'
&& next.status === 'pending'
&& phaseRank(next.phase) < phaseRank(current.phase);
if (wouldRegressReadyState || wouldRegressPendingPhase) {
return current;
}
state.set(key, next);
return next;
};
const getGitWorktreeBootstrapStatus = async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
const runtimeGit = getRegisteredRuntimeAPIs()?.git;
@@ -35,8 +91,10 @@ export const markWorktreeBootstrapPending = (directory: string): void => {
if (!key) {
return;
}
startLifecycle(key);
state.set(key, {
status: 'pending',
phase: 'directory-created',
error: null,
updatedAt: Date.now(),
});
@@ -47,13 +105,9 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
if (!key) {
return;
}
const watcher = watchers.get(key);
if (watcher) {
watcher.cancelled = true;
watchers.delete(key);
}
startLifecycle(key);
state.delete(key);
waiters.delete(key);
lifecycleVersions.delete(key);
};
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
@@ -61,10 +115,8 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots
if (!key) {
return;
}
startLifecycle(key);
state.set(key, next);
if (next.status !== 'pending') {
waiters.delete(key);
}
};
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
@@ -97,19 +149,34 @@ const markBootstrapFailed = (
return failed;
};
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => {
const hasReachedTarget = (status: GitWorktreeBootstrapStatus, target: WorktreeBootstrapTarget): boolean => {
if (status.status === 'ready') return true;
if (target === 'git-ready' && (status.phase === 'git-ready' || status.phase === 'setup-ready')) return true;
return false;
};
const pollWorktreeBootstrapUntilSettled = async (
directory: string,
key: string,
lifecycleVersion: number,
timeoutMs: number,
target: WorktreeBootstrapTarget,
): Promise<void> => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const result = await getGitWorktreeBootstrapStatus(directory);
setWorktreeBootstrapState(directory, result);
const current = storePolledState(key, result, lifecycleVersion);
if (!current) {
throw new Error('Worktree bootstrap wait was cancelled');
}
if (result.status === 'ready') {
if (hasReachedTarget(current, target)) {
return;
}
if (result.status === 'failed') {
throw new Error(result.error || 'Worktree bootstrap failed');
if (current.status === 'failed') {
throw new Error(current.error || 'Worktree bootstrap failed');
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
@@ -121,7 +188,8 @@ const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: n
const pollWorktreeBootstrapInBackground = async (
directory: string,
watcher: { cancelled: boolean },
key: string,
watcher: { cancelled: boolean; lifecycleVersion: number },
timeoutMs: number,
pollIntervalMs: number,
onFailed?: WorktreeBootstrapFailureHandler,
@@ -134,17 +202,20 @@ const pollWorktreeBootstrapInBackground = async (
if (watcher.cancelled) {
return;
}
setWorktreeBootstrapState(directory, result);
if (result.status === 'ready') {
onReady?.(result);
const current = storePolledState(key, result, watcher.lifecycleVersion);
if (!current) {
return;
}
if (result.status === 'failed') {
onFailed?.(result);
if (current.status === 'ready') {
onReady?.(current);
return;
}
if (current.status === 'failed') {
onFailed?.(current);
toast.error(t('worktree.bootstrap.toast.failed'), {
description: result.error || t('worktree.bootstrap.toast.failedDescription'),
description: current.error || t('worktree.bootstrap.toast.failedDescription'),
});
return;
}
@@ -183,9 +254,13 @@ export const startWorktreeBootstrapWatcher = (
return;
}
const watcher = { cancelled: false, promise: Promise.resolve() };
watcher.promise = pollWorktreeBootstrapInBackground(
const watcher = {
cancelled: false,
lifecycleVersion: lifecycleVersions.get(key) ?? 0,
};
void pollWorktreeBootstrapInBackground(
directory,
key,
watcher,
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
options?.pollIntervalMs ?? POLL_INTERVAL_MS,
@@ -211,7 +286,11 @@ export const startWorktreeBootstrapWatcher = (
watchers.set(key, watcher);
};
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => {
const waitForWorktreePhase = async (
directory: string,
target: WorktreeBootstrapTarget,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<void> => {
const key = getKey(directory);
if (!key) {
return;
@@ -222,21 +301,31 @@ export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DE
return;
}
if (current?.status === 'ready') {
if (hasReachedTarget(current, target)) {
return;
}
if (current?.status === 'failed') {
throw new Error(current.error || 'Worktree bootstrap failed');
}
const existing = waiters.get(key);
const waiterKey = getWaiterKey(key, target);
const existing = waiters.get(waiterKey);
if (existing) {
return existing;
}
const pending = pollWorktreeBootstrapUntilSettled(directory, timeoutMs).finally(() => {
waiters.delete(key);
const lifecycleVersion = lifecycleVersions.get(key) ?? 0;
const pending = pollWorktreeBootstrapUntilSettled(directory, key, lifecycleVersion, timeoutMs, target).finally(() => {
if (waiters.get(waiterKey) === pending) {
waiters.delete(waiterKey);
}
});
waiters.set(key, pending);
waiters.set(waiterKey, pending);
return pending;
};
export const waitForWorktreeGitReady = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
waitForWorktreePhase(directory, 'git-ready', timeoutMs);
export const waitForWorktreeBootstrap = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
waitForWorktreePhase(directory, 'setup-ready', timeoutMs);
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { GitWorktreeCreateResult } from '@/lib/api/types';
import type { WorktreeMetadata } from '@/types/worktree';
type WorktreeListEntry = {
@@ -11,16 +12,24 @@ type WorktreeListEntry = {
const listCalls: string[] = [];
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
const createdWorktree = {
head: 'abc123',
name: 'feature',
branch: 'feature',
path: '/repo-feature',
directoryCreated: true as const,
bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 },
};
let createdWorktreeResult: GitWorktreeCreateResult = createdWorktree;
const bootstrapWatcherCalls: string[] = [];
const bootstrapWatcherOptions: Array<{ onReady?: () => void }> = [];
const sessionState = {
availableWorktreesByProject: new Map<string, WorktreeMetadata[]>(),
availableWorktrees: [] as WorktreeMetadata[],
worktreeMetadata: new Map<string, WorktreeMetadata>(),
};
const attachmentState = {
attachments: new Map<string, { worktreeStatus: 'pending' | 'ready'; worktreeRoot: string }>(),
};
mock.module('@/lib/openchamberConfig', () => ({
@@ -31,7 +40,19 @@ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
clearWorktreeBootstrapState: mock(),
markWorktreeBootstrapPending: mock(),
setWorktreeBootstrapState: mock(),
startWorktreeBootstrapWatcher: mock(),
startWorktreeBootstrapWatcher: (directory: string, options?: { onReady?: () => void }) => {
bootstrapWatcherCalls.push(directory);
bootstrapWatcherOptions.push(options ?? {});
},
}));
mock.module('@/sync/session-worktree-store', () => ({
useSessionWorktreeStore: {
setState: (patch: Partial<typeof attachmentState> | ((state: typeof attachmentState) => Partial<typeof attachmentState>)) => {
const next = typeof patch === 'function' ? patch(attachmentState) : patch;
Object.assign(attachmentState, next);
},
},
}));
mock.module('@/lib/worktrees/worktreeStatus', () => ({
@@ -59,13 +80,13 @@ mock.module('@/lib/gitApi', () => ({
listResolvers.push(resolve);
});
},
create: mock(() => Promise.resolve(createdWorktree)),
create: mock(() => Promise.resolve(createdWorktreeResult)),
remove: mock(() => Promise.resolve({ success: true })),
},
},
}));
const { createWorktree, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
const waitForListCallCount = async (count: number): Promise<void> => {
for (let attempt = 0; attempt < 10; attempt += 1) {
@@ -81,8 +102,13 @@ describe('worktreeManager list invalidation', () => {
beforeEach(() => {
listCalls.length = 0;
listResolvers.length = 0;
bootstrapWatcherCalls.length = 0;
bootstrapWatcherOptions.length = 0;
createdWorktreeResult = createdWorktree;
sessionState.availableWorktreesByProject = new Map();
sessionState.availableWorktrees = [];
sessionState.worktreeMetadata = new Map();
attachmentState.attachments = new Map();
});
test('retries an in-flight list when a worktree is created before it resolves', async () => {
@@ -119,6 +145,62 @@ describe('worktreeManager list invalidation', () => {
expect(metadata.worktreeStatus).toBe('pending');
expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending');
expect(bootstrapWatcherCalls).toEqual(['/repo-feature']);
});
test('treats legacy create responses without bootstrap state as fully ready', async () => {
createdWorktreeResult = {
head: '',
name: 'legacy-feature',
branch: 'legacy-feature',
path: '/repo-legacy-feature',
};
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'legacy-feature',
mode: 'new',
branchName: 'legacy-feature',
worktreeName: 'legacy-feature',
returnAfterDirectoryCreated: true,
});
expect(metadata.worktreeStatus).toBe('ready');
expect(bootstrapWatcherCalls).toEqual([]);
});
test('reconciles session attachments when bootstrap becomes ready', async () => {
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'feature',
mode: 'new',
branchName: 'feature',
worktreeName: 'feature',
returnAfterDirectoryCreated: true,
});
sessionState.worktreeMetadata.set('session-1', metadata);
attachmentState.attachments.set('session-1', {
worktreeRoot: metadata.path,
worktreeStatus: 'pending',
});
bootstrapWatcherOptions[0]?.onReady?.();
expect(sessionState.worktreeMetadata.get('session-1')?.worktreeStatus).toBe('ready');
expect(attachmentState.attachments.get('session-1')?.worktreeStatus).toBe('ready');
});
test('resolves ready metadata when bootstrap settles before the session is attached', async () => {
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'feature',
mode: 'new',
branchName: 'feature',
worktreeName: 'feature',
returnAfterDirectoryCreated: true,
});
bootstrapWatcherOptions[0]?.onReady?.();
expect(metadata.worktreeStatus).toBe('pending');
expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready');
});
});
@@ -17,6 +17,7 @@ import type {
GitWorktreeValidationResult,
} from '@/lib/api/types';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
type WorktreeListEntry = {
path?: string;
@@ -57,6 +58,18 @@ const normalizePath = (value: string): string => {
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
export const getLatestWorktreeMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => {
const target = normalizePath(metadata.path);
const state = useSessionUIStore.getState();
const available = state.availableWorktrees.find((candidate) => normalizePath(candidate.path) === target);
if (available) return available;
for (const worktrees of state.availableWorktreesByProject.values()) {
const candidate = worktrees.find((worktree) => normalizePath(worktree.path) === target);
if (candidate) return candidate;
}
return metadata;
};
const slugifyWorktreeName = (value: string): string => {
return value
.trim()
@@ -86,6 +99,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
return;
}
const changedSessionIds: string[] = [];
useSessionUIStore.setState((state) => {
let changed = false;
@@ -131,6 +145,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
for (const [sessionId, metadata] of state.worktreeMetadata) {
const next = applyStatus(metadata);
if (next !== metadata) {
changedSessionIds.push(sessionId);
if (worktreeMetadata === state.worktreeMetadata) {
worktreeMetadata = new Map(state.worktreeMetadata);
}
@@ -148,6 +163,19 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
worktreeMetadata,
};
});
if (changedSessionIds.length > 0) {
useSessionWorktreeStore.setState((state) => {
let attachments = state.attachments;
for (const sessionId of changedSessionIds) {
const attachment = attachments.get(sessionId);
if (!attachment || attachment.worktreeStatus === status) continue;
if (attachments === state.attachments) attachments = new Map(state.attachments);
attachments.set(sessionId, { ...attachment, worktreeStatus: status });
}
return attachments === state.attachments ? state : { attachments };
});
}
};
const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => {
@@ -404,13 +432,17 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
if (created?.bootstrapStatus) {
setWorktreeBootstrapState(metadata.path, created.bootstrapStatus);
} else {
} else if (created?.directoryCreated) {
markWorktreeBootstrapPending(metadata.path);
}
startWorktreeBootstrapWatcher(metadata.path, {
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
});
const shouldWatchBootstrap = created?.bootstrapStatus?.status === 'pending'
|| (!created?.bootstrapStatus && created?.directoryCreated === true);
if (shouldWatchBootstrap) {
startWorktreeBootstrapWatcher(metadata.path, {
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
});
}
invalidateWorktreeList(projectDirectory);
// The new worktree changes the repo's worktree topology; drop cached root