From 3fd6627196f2328226f07b75c10b589b4da8bd5f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 19 Jul 2026 00:00:31 +0300 Subject: [PATCH] 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. --- packages/ui/src/components/icon/sprite.ts | 1 + .../session/sidebar/DOCUMENTATION.md | 1 + .../session/sidebar/SessionNodeItem.tsx | 65 ++++++- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/i18n/messages/en.ts | 7 + packages/ui/src/lib/i18n/messages/es.ts | 7 + packages/ui/src/lib/i18n/messages/fr.ts | 7 + packages/ui/src/lib/i18n/messages/ja.ts | 7 + packages/ui/src/lib/i18n/messages/ko.ts | 7 + packages/ui/src/lib/i18n/messages/pl.ts | 7 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 7 + packages/ui/src/lib/i18n/messages/uk.ts | 7 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 7 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 7 + packages/ui/src/lib/worktreeSessionCreator.ts | 70 ++++--- .../src/lib/worktrees/sessionWorktreeMove.ts | 180 ++++++++++++++++++ .../lib/worktrees/worktreeBootstrap.test.ts | 113 ++++++++++- .../ui/src/lib/worktrees/worktreeBootstrap.ts | 151 ++++++++++++--- .../src/lib/worktrees/worktreeManager.test.ts | 88 ++++++++- .../ui/src/lib/worktrees/worktreeManager.ts | 42 +++- packages/ui/src/sync/DOCUMENTATION.md | 6 +- packages/ui/src/sync/session-actions.test.ts | 94 +++++++++ packages/ui/src/sync/session-actions.ts | 109 +++++++++++ packages/vscode/src/DOCUMENTATION.md | 5 + .../vscode/src/bridge-git-runtime.test.js | 66 +++++++ packages/vscode/src/gitService.ts | 126 +++++++++--- .../src/gitService.worktree-bootstrap.test.js | 18 ++ packages/vscode/webview/api/git.ts | 17 +- packages/web/server/lib/git/DOCUMENTATION.md | 3 +- packages/web/server/lib/git/service.js | 117 ++++++++---- packages/web/server/lib/git/service.test.js | 130 +++++++++++++ 31 files changed, 1322 insertions(+), 151 deletions(-) create mode 100644 packages/ui/src/lib/worktrees/sessionWorktreeMove.ts create mode 100644 packages/vscode/src/gitService.worktree-bootstrap.test.js diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 291c3843..eccfc004 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -112,6 +112,7 @@ export const iconSpriteData = { "folder-open-fill": ``, "folder-open": ``, "folder-received": ``, + "folder-shared": ``, "folders": ``, "fullscreen-exit": ``, "fullscreen": ``, diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index ce8c411c..1a833d72 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -9,6 +9,7 @@ - Active/hover row styling is text-first; selected sessions use primary text instead of background fills. - Archived groups are collapsed by default and support bulk deletion at group/folder level. - Session rows support compact inline dates in minimal mode and simplified metadata in default mode. +- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle. - New extractions in latest pass reduced local effect/callback bulk further: - project session list builders - folder cleanup sync diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index c8bb8d3a..88b33aff 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -42,6 +42,7 @@ import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { FusionIcon } from '@/components/icons/FusionIcon'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; +import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; type Folder = { id: string; name: string; sessionIds: string[] }; @@ -346,6 +347,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { return out; }, []); + const collectNodeDescendantSessions = React.useCallback((root: SessionNode): Session[] => { + const out: Session[] = []; + const walk = (current: SessionNode) => { + current.children.forEach((child) => { + out.push(child.session); + walk(child); + }); + }; + walk(root); + return out; + }, []); + const [exportDialogOpen, setExportDialogOpen] = React.useState(false); const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true); @@ -354,6 +367,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]), ); const sessionStatus = useGlobalSessionStatus(session.id); + const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); const sessionGoal = getSessionGoal(resolvedSession); const sessionGoalGlyph = sessionGoal ? ( @@ -578,7 +592,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const statusType = sessionStatus?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const pendingPermissionCount = sessionPermissions.length; - const showUnreadStatus = !isStreaming && needsAttention && !isActive; + const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive; const showStatusMarker = isStreaming || showUnreadStatus; const statusMarkerContent = isStreaming ? ( @@ -595,8 +609,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { title={t('sessions.sidebar.session.status.unread')} /> ); - const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (showStatusMarker || isPinnedSession); - const showPinnedMarker = isPinnedSession && !showStatusMarker; + const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession); + const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker; const pinnedMarkerContent = ( ); - const leadingIndicators = showStatusMarker || showPinnedMarker ? ( + const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? ( - {showStatusMarker ? statusMarkerContent : null} - {showPinnedMarker ? pinnedMarkerContent : null} + {isMovingToWorktree ? ( + + ) : showStatusMarker ? statusMarkerContent : showPinnedMarker ? pinnedMarkerContent : null} ) : null; - const hideChevronUntilHover = hasChildren && !alwaysShowActions && (showStatusMarker || isPinnedSession); + const hideChevronUntilHover = hasChildren && !alwaysShowActions && (isMovingToWorktree || showStatusMarker || isPinnedSession); const subsessionChevron = hasChildren ? ( {t('sessions.sidebar.session.menu.exportMarkdown')} + {!isSubtaskSession && !archivedBucket && !isVSCode ? ( + + + + { + if (!sessionDirectory || isStreaming || isMovingToWorktree) return; + startSessionTreeWorktreeMove({ + root: resolvedSession, + descendants: collectNodeDescendantSessions(node), + sourceDirectory: sessionDirectory, + successMessage: t('sessions.sidebar.session.moveToWorktree.success'), + failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), + }); + }} + className="w-full [&>svg]:mr-1" + > + + {t('sessions.sidebar.session.menu.moveToWorktree')} + + + + + {isMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isStreaming + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltip')} + + + ) : null} {isMultiRunLikeSession ? ( setFusionDialogOpen(true)} className="[&>svg]:mr-1"> diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c0f5835c..887e2d07 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; } diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index d335b199..539088ac 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index e96f2cf3..2368e444 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -457,6 +457,12 @@ export const dict: Record = { "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 = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 0eadafaa..c8c04def 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index b4600a2b..6a5b7c97 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -457,6 +457,12 @@ export const dict: Record = { '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 = { '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': 'サブセッションを展開', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 6aeff513..b99f498b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -457,6 +457,12 @@ export const dict: Record = { '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 = { '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': '하위 세션 펼치기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 825bed35..938da259 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -262,6 +262,12 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index eb95fd31..591574ab 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -457,6 +457,12 @@ export const dict: Record = { "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 = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 850454ae..e102b76d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -457,6 +457,12 @@ export const dict: Record = { "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 = { "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": "Розгорнути підсесії", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 3b69bda2..6f8ba4fa 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -457,6 +457,12 @@ export const dict: Record = { '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 = { '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': '展开子会话', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index adb8366d..c779e396 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -470,6 +470,12 @@ export const dict: Record = { '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 = { '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': '展開子會話', diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index c93da014..3b39dc05 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -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 => { 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({ diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts new file mode 100644 index 00000000..9ddb5e57 --- /dev/null +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -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 }>(() => ({ + 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 => { + 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, +): Promise => { + 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 => { + 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 => { + 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), + })); +}; diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts index 12ff2919..89204a80 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts @@ -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 => 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 => { @@ -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 }> = []; diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts index 97c97f2b..9b047a70 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts @@ -14,10 +14,66 @@ const POLL_INTERVAL_MS = 250; const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value; const state = new Map(); +type WorktreeBootstrapTarget = 'git-ready' | 'setup-ready'; + const waiters = new Map>(); -const watchers = new Map }>(); +const lifecycleVersions = new Map(); +let nextLifecycleVersion = 0; +const watchers = new Map(); 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 => { 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 => { +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 => { 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 => { +const waitForWorktreePhase = async ( + directory: string, + target: WorktreeBootstrapTarget, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise => { 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 => + waitForWorktreePhase(directory, 'git-ready', timeoutMs); + +export const waitForWorktreeBootstrap = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise => + waitForWorktreePhase(directory, 'setup-ready', timeoutMs); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 944c3f59..f2aab3b6 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -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(), availableWorktrees: [] as WorktreeMetadata[], + worktreeMetadata: new Map(), +}; +const attachmentState = { + attachments: new Map(), }; 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 | ((state: typeof attachmentState) => Partial)) => { + 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 => { 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'); }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index bccd773a..2ba1edc4 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -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 { let changed = false; @@ -131,6 +145,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable 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 diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 9a571347..41109fc5 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -100,8 +100,9 @@ VS Code does not run the server permission-auto-accept runtime. The extension ho - title update - share - unshare - - archive - - delete + - archive + - delete + - move to another worktree directory - retention cleanup batch archive/delete This keeps cold/global lists responsive without requiring a refetch after every change. @@ -125,6 +126,7 @@ Examples of global-store updates performed in `session-actions.ts`: - `shareSession()` / `unshareSession()` -> `upsertSession(result.data)` - `archiveSession()` -> `archiveSessions([id], archivedAt)` - `deleteSession()` -> `removeSessions([id])` +- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index ## The golden rule diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index ae4df0a3..8e862696 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -13,6 +13,7 @@ let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] } const globalUpsertedSessions: unknown[] = [] +const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = [] const mockScopedClient = { permission: { @@ -40,6 +41,14 @@ const mockScopedClient = { } const mockSdk = { + experimental: { + controlPlane: { + moveSession: mock((params: Record) => { + replyCalls.push({ method: "controlPlane.moveSession", params }) + return Promise.resolve({}) + }), + }, + }, session: { messages: mock((params: Record) => { replyCalls.push({ method: "session.messages", params }) @@ -102,6 +111,7 @@ mock.module("@/lib/opencode/client", () => ({ return mockScopedClient }, getDirectory: () => "/test/project", + getSdkClient: () => mockSdk, replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) return Promise.resolve(true) @@ -147,6 +157,9 @@ mock.module("./session-ui-store", () => ({ if (sessionId === "session-b") return "/other/project" return null }, + setSessionDirectory: (sessionID: string, directory: string) => { + movedSessionDirectories.push({ sessionID, directory }) + }, }), }, })) @@ -234,6 +247,87 @@ function createChildStores(entries: Array<[string, StoreApi]>) { } as unknown as import("./child-store").ChildStoreManager } +describe("moveSessionToDirectory", () => { + beforeEach(() => { + replyCalls.length = 0 + registeredSessionDirectories.length = 0 + movedSessionDirectories.length = 0 + globalUpsertedSessions.length = 0 + }) + + test("moves through the control plane and reconciles directory stores", async () => { + const message = { + id: "message-a", + sessionID: "session-a", + role: "user", + time: { created: 1 }, + } as Message + const part = { + id: "part-a", + messageID: "message-a", + type: "text", + text: "hello", + } as Part + const source = createStore({ "session-a": [{ id: "permission-a" }] as never }, { + session: [{ id: "session-a", title: "Move me", directory: "/source" } as Session], + sessionTotal: 1, + session_status: { "session-a": { type: "idle" } }, + session_diff: { "session-a": [{ file: "changed.ts", additions: 1, deletions: 0 }] }, + todo: { "session-a": [{ id: "todo-a", content: "Check move", status: "pending", priority: "medium" }] as never }, + question: { "session-a": [{ id: "question-a" }] as never }, + message: { "session-a": [message] }, + part: { "message-a": [part] }, + }) + const destination = createStore({}) + const childStores = createChildStores([["/source", source], ["/destination", destination]]) + const { moveSessionToDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/source") + + await moveSessionToDirectory(source.getState().session[0], "/source", "/destination", true) + + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([{ + method: "controlPlane.moveSession", + params: { + sessionID: "session-a", + destination: { directory: "/destination" }, + moveChanges: true, + }, + }]) + expect(source.getState().session).toHaveLength(0) + expect(source.getState().sessionTotal).toBe(0) + expect(source.getState().session_status["session-a"]).toBe(undefined) + expect(source.getState().session_diff["session-a"]).toBe(undefined) + expect(source.getState().todo["session-a"]).toBe(undefined) + expect(source.getState().permission["session-a"]).toBe(undefined) + expect(source.getState().question["session-a"]).toBe(undefined) + expect(source.getState().message["session-a"]).toBe(undefined) + expect(source.getState().part["message-a"]).toBe(undefined) + expect(destination.getState().session[0]?.id).toBe("session-a") + expect(destination.getState().sessionTotal).toBe(1) + expect((destination.getState().session[0] as SessionWithDirectory)?.directory).toBe("/destination") + expect(destination.getState().session_status["session-a"]?.type).toBe("idle") + expect(destination.getState().session_diff["session-a"]?.[0]?.file).toBe("changed.ts") + expect(destination.getState().todo["session-a"]?.[0]?.content).toBe("Check move") + expect(destination.getState().permission["session-a"]?.[0]?.id).toBe("permission-a") + expect(destination.getState().question["session-a"]?.[0]?.id).toBe("question-a") + expect(destination.getState().message["session-a"]?.[0]?.id).toBe("message-a") + expect(destination.getState().part["message-a"]?.[0]?.id).toBe("part-a") + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }]) + expect(movedSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }]) + expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe("/destination") + + await moveSessionToDirectory(destination.getState().session[0], "/destination", "/source", true) + + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")[1]?.params.moveChanges).toBe(true) + expect(source.getState().session[0]?.id).toBe("session-a") + expect(source.getState().message["session-a"]?.[0]?.id).toBe("message-a") + expect(source.getState().part["message-a"]?.[0]?.id).toBe("part-a") + expect(destination.getState().session).toHaveLength(0) + expect(destination.getState().message["session-a"]).toBe(undefined) + expect(destination.getState().part["message-a"]).toBe(undefined) + }) +}) + describe("fetchMessagesForSession startup race", () => { test("does not reject before sync action refs are initialized", async () => { const { fetchMessagesForSession } = await import("./session-actions") diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 6d5aaf23..48e28481 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -192,6 +192,115 @@ export function mirrorSessionIntoLiveStores(session: Session, directory?: string updateLiveSession(session) } +function moveRecordEntries( + source: Record, + destination: Record, + keys: Iterable, +): { source: Record; destination: Record } { + let nextSource = source + let nextDestination = destination + + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue + if (nextSource === source) nextSource = { ...source } + if (nextDestination === destination) nextDestination = { ...destination } + nextDestination[key] = source[key] + delete nextSource[key] + } + + return { source: nextSource, destination: nextDestination } +} + +function reconcileSessionMove( + session: Session, + sourceDirectory: string, + destinationDirectory: string, +): Session { + const stores = _childStores + const sourceStore = stores?.getChild(sourceDirectory) + const destinationStore = stores?.ensureChild(destinationDirectory, { bootstrap: false }) + const sourceState = sourceStore?.getState() + const destinationState = destinationStore?.getState() + const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) ?? session + const movedSession = { ...liveSession, directory: destinationDirectory } as Session + + if (!destinationStore || !destinationState || sourceStore === destinationStore) { + return movedSession + } + + const destinationSessionIndex = destinationState.session.findIndex((candidate) => candidate.id === session.id) + const destinationSessions = [...destinationState.session] + if (destinationSessionIndex === -1) destinationSessions.push(movedSession) + else destinationSessions[destinationSessionIndex] = movedSession + + if (!sourceStore || !sourceState) { + destinationStore.setState({ + session: destinationSessions, + sessionTotal: destinationSessionIndex === -1 + ? destinationState.sessionTotal + 1 + : destinationState.sessionTotal, + }) + return movedSession + } + + const sourceContainsSession = sourceState.session.some((candidate) => candidate.id === session.id) + const status = moveRecordEntries(sourceState.session_status, destinationState.session_status, [session.id]) + const diffs = moveRecordEntries(sourceState.session_diff, destinationState.session_diff, [session.id]) + const todos = moveRecordEntries(sourceState.todo, destinationState.todo, [session.id]) + const permissions = moveRecordEntries(sourceState.permission, destinationState.permission, [session.id]) + const questions = moveRecordEntries(sourceState.question, destinationState.question, [session.id]) + const messages = moveRecordEntries(sourceState.message, destinationState.message, [session.id]) + const messageIds = sourceState.message[session.id]?.map((message) => message.id) ?? [] + const parts = moveRecordEntries(sourceState.part, destinationState.part, messageIds) + + sourceStore.setState({ + session: sourceState.session.filter((candidate) => candidate.id !== session.id), + sessionTotal: sourceContainsSession ? Math.max(0, sourceState.sessionTotal - 1) : sourceState.sessionTotal, + session_status: status.source, + session_diff: diffs.source, + todo: todos.source, + permission: permissions.source, + question: questions.source, + message: messages.source, + part: parts.source, + }) + destinationStore.setState({ + session: destinationSessions, + sessionTotal: destinationSessionIndex === -1 + ? destinationState.sessionTotal + 1 + : destinationState.sessionTotal, + session_status: status.destination, + session_diff: diffs.destination, + todo: todos.destination, + permission: permissions.destination, + question: questions.destination, + message: messages.destination, + part: parts.destination, + }) + + return movedSession +} + +export async function moveSessionToDirectory( + session: Session, + sourceDirectory: string, + destinationDirectory: string, + moveChanges = true, +): Promise { + const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({ + sessionID: session.id, + destination: { directory: destinationDirectory }, + moveChanges, + }) + assertSdkSuccess(result, "Move session") + + const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory) + + registerSessionDirectory(session.id, destinationDirectory) + useGlobalSessionsStore.getState().upsertSession(moved) + useSessionUIStore.getState().setSessionDirectory(session.id, destinationDirectory) +} + function dir() { return _getDirectory() || undefined } diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index b550f1e8..eb4085ba 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -21,6 +21,11 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t - `bridge-git-process-runtime.ts` - Git process execution and environment setup (`execGit`), including SSH agent socket resolution. +- `gitService.ts` + - Owns VS Code Git and worktree operations. + - Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`. + - Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state. + - `bridge-fs-runtime.ts` - Bridge handlers for filesystem-related message routes. - Uses shared FS helpers via injected dependencies. diff --git a/packages/vscode/src/bridge-git-runtime.test.js b/packages/vscode/src/bridge-git-runtime.test.js index 3d5d79ac..e5251727 100644 --- a/packages/vscode/src/bridge-git-runtime.test.js +++ b/packages/vscode/src/bridge-git-runtime.test.js @@ -7,6 +7,8 @@ const gitService = { cherryPick: mock(), revertCommit: mock(), resetToCommit: mock(), + createWorktree: mock(), + getWorktreeBootstrapStatus: mock(), }; mock.module('./gitService', () => gitService); @@ -21,6 +23,8 @@ describe('bridge git runtime index mutations', () => { gitService.cherryPick.mockReset(); gitService.revertCommit.mockReset(); gitService.resetToCommit.mockReset(); + gitService.createWorktree.mockReset(); + gitService.getWorktreeBootstrapStatus.mockReset(); }); it('accepts legacy stage path payloads', async () => { @@ -109,4 +113,66 @@ describe('bridge git runtime index mutations', () => { expect(gitService.revertCommit).not.toHaveBeenCalled(); expect(gitService.resetToCommit).not.toHaveBeenCalled(); }); + + it('preserves bootstrap phases in status responses', async () => { + const bootstrapStatus = { + status: 'pending', + phase: 'git-ready', + error: null, + updatedAt: 123, + }; + gitService.getWorktreeBootstrapStatus.mockResolvedValue(bootstrapStatus); + + const response = await handleStandardGitBridgeMessage({ + id: 'bootstrap-status', + type: 'api:git/worktrees/bootstrap-status', + payload: { directory: '/repo-worktree' }, + }); + + expect(response).toEqual({ + id: 'bootstrap-status', + type: 'api:git/worktrees/bootstrap-status', + success: true, + data: bootstrapStatus, + }); + expect(gitService.getWorktreeBootstrapStatus).toHaveBeenCalledWith('/repo-worktree'); + }); + + it('preserves the directory-created phase in fast create responses', async () => { + const created = { + head: '', + name: 'feature', + branch: 'openchamber/feature', + path: '/repo-worktree', + directoryCreated: true, + bootstrapStatus: { + status: 'pending', + phase: 'directory-created', + error: null, + updatedAt: 123, + }, + }; + gitService.createWorktree.mockResolvedValue(created); + + const response = await handleStandardGitBridgeMessage({ + id: 'create-worktree', + type: 'api:git/worktrees', + payload: { + directory: '/repo', + method: 'POST', + worktreeName: 'feature', + returnAfterDirectoryCreated: true, + }, + }); + + expect(response).toEqual({ + id: 'create-worktree', + type: 'api:git/worktrees', + success: true, + data: created, + }); + expect(gitService.createWorktree).toHaveBeenCalledWith('/repo', expect.objectContaining({ + returnAfterDirectoryCreated: true, + })); + }); }); diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index c2d707a5..4d915d80 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -14,11 +14,23 @@ import type { API as GitAPI, Repository, GitExtension, Status } from './git.d'; let gitApi: GitAPI | null = null; let gitExtensionEnabled = false; -const worktreeBootstrapState = new Map(); + +type WorktreeBootstrapStatus = { + status: 'pending' | 'ready' | 'failed'; + phase: 'directory-created' | 'git-ready' | 'setup-ready'; + error: string | null; + updatedAt: number; +}; + +const worktreeBootstrapState = new Map(); +const activeWorktreeBootstrapTasks = new Map>(); const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const; const WORKTREE_BOOTSTRAP_READY = 'ready' as const; const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const; +const WORKTREE_PHASE_DIRECTORY_CREATED = 'directory-created' as const; +const WORKTREE_PHASE_GIT_READY = 'git-ready' as const; +const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const; const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; @@ -30,16 +42,35 @@ const toBootstrapStateKey = (directory: string): string => { return path.resolve(normalized); }; -const setWorktreeBootstrapState = (directory: string, status: 'pending' | 'ready' | 'failed', error: string | null = null): void => { +const setWorktreeBootstrapState = ( + directory: string, + status: WorktreeBootstrapStatus['status'], + phase: WorktreeBootstrapStatus['phase'], + error: string | null = null, +): WorktreeBootstrapStatus | null => { const key = toBootstrapStateKey(directory); if (!key) { - return; + return null; } - worktreeBootstrapState.set(key, { + + const state: WorktreeBootstrapStatus = { status, + phase, error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null, updatedAt: Date.now(), - }); + }; + worktreeBootstrapState.set(key, state); + return state; +}; + +const setWorktreeBootstrapFailure = (directory: string, error: unknown): void => { + const current = worktreeBootstrapState.get(toBootstrapStateKey(directory)); + setWorktreeBootstrapState( + directory, + WORKTREE_BOOTSTRAP_FAILED, + current?.phase ?? WORKTREE_PHASE_DIRECTORY_CREATED, + error instanceof Error ? error.message : String(error), + ); }; const clearWorktreeBootstrapState = (directory: string): void => { @@ -50,6 +81,36 @@ const clearWorktreeBootstrapState = (directory: string): void => { worktreeBootstrapState.delete(key); }; +const trackWorktreeBootstrapTask = (directory: string, task: Promise): void => { + const key = toBootstrapStateKey(directory); + if (!key) { + return; + } + + activeWorktreeBootstrapTasks.set(key, task); + const clearTask = () => { + if (activeWorktreeBootstrapTasks.get(key) === task) { + activeWorktreeBootstrapTasks.delete(key); + } + }; + void task.then(clearTask, clearTask); +}; + +const waitForActiveWorktreeBootstrap = async (directory: string): Promise => { + const key = toBootstrapStateKey(directory); + if (!key) { + return; + } + + while (true) { + const task = activeWorktreeBootstrapTasks.get(key); + if (!task) { + return; + } + await task.catch(() => undefined); + } +}; + const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; @@ -780,7 +841,7 @@ export interface GitWorktreeInfo { branch: string; path: string; directoryCreated?: true; - bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }; + bootstrapStatus?: WorktreeBootstrapStatus; } type WorktreeListEntry = { @@ -1485,8 +1546,8 @@ const queueWorktreeBootstrap = (args: { ensureRemoteUrl, startCommand, } = args; - setTimeout(() => { - const run = async () => { + const task = new Promise((resolve) => setTimeout(resolve, 0)) + .then(async () => { await populateWorktreeWithLockRecovery(directory); if (setUpstream) { await applyUpstreamConfiguration({ @@ -1502,21 +1563,18 @@ const queueWorktreeBootstrap = (args: { console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error)); }); } + setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_GIT_READY); await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => { console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error)); }); - setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY); - }; - - void run().catch((error) => { - setWorktreeBootstrapState( - directory, - WORKTREE_BOOTSTRAP_FAILED, - error instanceof Error ? error.message : String(error) - ); + setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY); + }) + .catch((error) => { + setWorktreeBootstrapFailure(directory, error); console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error)); }); - }, 0); + + trackWorktreeBootstrapTask(directory, task); }; const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => { @@ -1915,9 +1973,13 @@ async function attachGitWorktreeToCandidate( const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); - setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); - const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { + const bootstrapStatus = setWorktreeBootstrapState( + candidate.directory, + WORKTREE_BOOTSTRAP_PENDING, + WORKTREE_PHASE_DIRECTORY_CREATED, + ) ?? { status: WORKTREE_BOOTSTRAP_PENDING, + phase: WORKTREE_PHASE_DIRECTORY_CREATED, error: null, updatedAt: Date.now(), }; @@ -1977,9 +2039,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); } - setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); - const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { + const bootstrapStatus = setWorktreeBootstrapState( + candidate.directory, + WORKTREE_BOOTSTRAP_PENDING, + WORKTREE_PHASE_DIRECTORY_CREATED, + ) ?? { status: WORKTREE_BOOTSTRAP_PENDING, + phase: WORKTREE_PHASE_DIRECTORY_CREATED, error: null, updatedAt: Date.now(), }; @@ -1987,15 +2053,12 @@ export async function createWorktree(directory: string, input: CreateGitWorktree ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) : candidate.branch; - void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => { - setWorktreeBootstrapState( - candidate.directory, - WORKTREE_BOOTSTRAP_FAILED, - error instanceof Error ? error.message : String(error) - ); - void cleanupFailedFastWorktreeCreate(context, candidate); + const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { + setWorktreeBootstrapFailure(candidate.directory, error); + await cleanupFailedFastWorktreeCreate(context, candidate); console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error)); }); + trackWorktreeBootstrapTask(candidate.directory, task); return { head: '', @@ -2010,7 +2073,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree return attachGitWorktreeToCandidate(context, candidate, input); } -export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> { +export async function getWorktreeBootstrapStatus(directory: string): Promise { const key = toBootstrapStateKey(directory); if (!key) { throw new Error('Worktree directory is required'); @@ -2023,6 +2086,7 @@ export async function getWorktreeBootstrapStatus(directory: string): Promise<{ s return { status: WORKTREE_BOOTSTRAP_READY, + phase: WORKTREE_PHASE_SETUP_READY, error: null, updatedAt: Date.now(), }; @@ -2034,6 +2098,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree throw new Error('Worktree directory is required'); } + await waitForActiveWorktreeBootstrap(targetDirectory); + const context = await resolveWorktreeProjectContext(directory); const deleteLocalBranch = input?.deleteLocalBranch === true; diff --git a/packages/vscode/src/gitService.worktree-bootstrap.test.js b/packages/vscode/src/gitService.worktree-bootstrap.test.js new file mode 100644 index 00000000..694739ba --- /dev/null +++ b/packages/vscode/src/gitService.worktree-bootstrap.test.js @@ -0,0 +1,18 @@ +import { describe, expect, it, mock } from 'bun:test'; + +mock.module('vscode', () => ({ + extensions: { getExtension: () => undefined }, + Uri: { file: (fsPath) => ({ fsPath }) }, +})); + +const { getWorktreeBootstrapStatus } = await import('./gitService.ts?worktree-bootstrap-test'); + +describe('VS Code worktree bootstrap phases', () => { + it('treats missing bootstrap state as fully ready', async () => { + await expect(getWorktreeBootstrapStatus('/untracked-worktree')).resolves.toMatchObject({ + status: 'ready', + phase: 'setup-ready', + error: null, + }); + }); +}); diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 60e35e46..bcfd23cb 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -18,6 +18,7 @@ import type { GeneratedCommitMessage, GeneratedPullRequestDescription, GitWorktreeInfo, + GitWorktreeBootstrapStatus, CreateGitWorktreePayload, GitWorktreeValidationResult, GitWorktreeCreateResult, @@ -41,6 +42,10 @@ import type { ResetToCommitResponse, } from '@openchamber/ui/lib/api/types'; +const requestWorktreeBootstrapStatus = (directory: string): Promise => { + return sendBridgeMessage('api:git/worktrees/bootstrap-status', { directory }); +}; + type GitIdentityStoreState = { profiles: GitIdentityProfile[]; }; @@ -193,10 +198,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({ }); }, - getGitWorktreeBootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => { - return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', { - directory, - }); + getGitWorktreeBootstrapStatus: async (directory: string): Promise => { + return requestWorktreeBootstrapStatus(directory); }, previewGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise => { @@ -514,10 +517,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({ ...(payload || {}), }); }, - bootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => { - return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', { - directory, - }); + bootstrapStatus: async (directory: string): Promise => { + return requestWorktreeBootstrapStatus(directory); }, preview: async (directory: string, payload: CreateGitWorktreePayload): Promise => { return sendBridgeMessage('api:git/worktrees/preview', { diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 21e6f12f..2af4f877 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -120,8 +120,9 @@ The following functions are internal helpers used by exported functions: - `branch`: Local branch name. - `path`: Absolute path to worktree directory. - `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues. -- `bootstrapStatus`: Background setup status, with `pending`, `ready`, or `failed`. +- `bootstrapStatus`: Background setup state. The legacy `status` remains `pending`, `ready`, or `failed`, while `phase` reports `directory-created`, `git-ready`, or `setup-ready`. Fast create starts at `pending`/`directory-created`; population and upstream Git completion advances to `pending`/`git-ready` before setup/start scripts; completed setup is `ready`/`setup-ready`. A missing in-memory state falls back to `ready`/`setup-ready`; clients continue to accept legacy status responses that omit `phase`. - Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup. +- Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup. - Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures. ### Log Response diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 2d9dc841..a23f0cea 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -12,6 +12,7 @@ const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; let resolvedGitBinary = null; const worktreeBootstrapState = new Map(); +const activeWorktreeBootstrapTasks = new Map(); const remoteExistenceCache = new Map(); const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i; const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed'; @@ -21,6 +22,9 @@ const gitIndexMutationQueues = new Map(); const WORKTREE_BOOTSTRAP_PENDING = 'pending'; const WORKTREE_BOOTSTRAP_READY = 'ready'; const WORKTREE_BOOTSTRAP_FAILED = 'failed'; +const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created'; +const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready'; +const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-ready'; const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; @@ -32,16 +36,21 @@ const toBootstrapStateKey = (directory) => { return path.resolve(normalized); }; -const setWorktreeBootstrapState = (directory, status, error = null) => { +const createWorktreeBootstrapState = (status, phase, error = null) => ({ + status, + phase, + error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null, + updatedAt: Date.now(), +}); + +const setWorktreeBootstrapState = (directory, status, phase, error = null) => { const key = toBootstrapStateKey(directory); if (!key) { - return; + return null; } - worktreeBootstrapState.set(key, { - status, - error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null, - updatedAt: Date.now(), - }); + const state = createWorktreeBootstrapState(status, phase, error); + worktreeBootstrapState.set(key, state); + return state; }; const clearWorktreeBootstrapState = (directory) => { @@ -52,6 +61,37 @@ const clearWorktreeBootstrapState = (directory) => { worktreeBootstrapState.delete(key); }; +const trackWorktreeBootstrapTask = (directory, task) => { + const key = toBootstrapStateKey(directory); + if (!key) { + return task; + } + + activeWorktreeBootstrapTasks.set(key, task); + const clearTask = () => { + if (activeWorktreeBootstrapTasks.get(key) === task) { + activeWorktreeBootstrapTasks.delete(key); + } + }; + void task.then(clearTask, clearTask); + return task; +}; + +const waitForActiveWorktreeBootstrap = async (directory) => { + const key = toBootstrapStateKey(directory); + if (!key) { + return; + } + + while (true) { + const task = activeWorktreeBootstrapTasks.get(key); + if (!task) { + return; + } + await task.catch(() => undefined); + } +}; + const isExecutableFile = (candidate) => { if (typeof candidate !== 'string' || candidate.trim().length === 0) { return false; @@ -1730,8 +1770,8 @@ const queueWorktreeBootstrap = (args) => { ensureRemoteUrl, startCommand, } = args; - setTimeout(() => { - const run = async () => { + const task = new Promise((resolve) => setTimeout(resolve, 0)) + .then(async () => { await populateWorktreeWithLockRecovery(directory); if (setUpstream) { await applyUpstreamConfiguration({ @@ -1747,21 +1787,31 @@ const queueWorktreeBootstrap = (args) => { console.warn('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error)); }); } + setWorktreeBootstrapState( + directory, + WORKTREE_BOOTSTRAP_PENDING, + WORKTREE_BOOTSTRAP_PHASE_GIT_READY + ); await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => { console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error)); }); - setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY); - }; - - void run().catch((error) => { + setWorktreeBootstrapState( + directory, + WORKTREE_BOOTSTRAP_READY, + WORKTREE_BOOTSTRAP_PHASE_SETUP_READY + ); + }) + .catch((error) => { setWorktreeBootstrapState( directory, WORKTREE_BOOTSTRAP_FAILED, + WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED, error instanceof Error ? error.message : String(error) ); console.warn('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error)); }); - }, 0); + + trackWorktreeBootstrapTask(directory, task); }; const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => { @@ -3756,12 +3806,11 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); - setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); - const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { - status: WORKTREE_BOOTSTRAP_PENDING, - error: null, - updatedAt: Date.now(), - }; + const bootstrapStatus = setWorktreeBootstrapState( + candidate.directory, + WORKTREE_BOOTSTRAP_PENDING, + WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED + ); queueWorktreeBootstrap({ directory: candidate.directory, @@ -3818,25 +3867,26 @@ export async function createWorktree(directory, input = {}) { console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error)); } - setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); - const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { - status: WORKTREE_BOOTSTRAP_PENDING, - error: null, - updatedAt: Date.now(), - }; + const bootstrapStatus = setWorktreeBootstrapState( + candidate.directory, + WORKTREE_BOOTSTRAP_PENDING, + WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED + ); const localBranch = mode === 'existing' ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) : candidate.branch; - void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => { + const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_FAILED, + WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED, error instanceof Error ? error.message : String(error) ); - void cleanupFailedFastWorktreeCreate(context, candidate); + await cleanupFailedFastWorktreeCreate(context, candidate); console.warn('Background worktree creation failed:', error instanceof Error ? error.message : String(error)); }); + trackWorktreeBootstrapTask(candidate.directory, task); return { head: '', @@ -3862,11 +3912,10 @@ export async function getWorktreeBootstrapStatus(directory) { return current; } - return { - status: WORKTREE_BOOTSTRAP_READY, - error: null, - updatedAt: Date.now(), - }; + return createWorktreeBootstrapState( + WORKTREE_BOOTSTRAP_READY, + WORKTREE_BOOTSTRAP_PHASE_SETUP_READY + ); } export async function removeWorktree(directory, input = {}) { @@ -3875,6 +3924,8 @@ export async function removeWorktree(directory, input = {}) { throw new Error('Worktree directory is required'); } + await waitForActiveWorktreeBootstrap(targetDirectory); + const context = await resolveWorktreeProjectContext(directory); const deleteLocalBranch = input?.deleteLocalBranch === true; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index daeb56bd..0e480b8e 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -9,6 +9,7 @@ import { checkoutCommit, cherryPick, createWorktree, + getWorktreeBootstrapStatus, getStatus, populateWorktreeWithLockRecovery, removeWorktree, @@ -322,6 +323,135 @@ describe('worktree root resolution', () => { // --------------------------------------------------------------------------- describe('createWorktree', () => { + it('returns ready/setup-ready when no bootstrap state is recorded', async () => { + const directory = path.join(createTempDir(), 'missing-worktree'); + + await expect(getWorktreeBootstrapStatus(directory)).resolves.toMatchObject({ + status: 'ready', + phase: 'setup-ready', + error: null, + }); + }); + + it('reports directory, Git, and setup bootstrap phases while preserving legacy status', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + const setupMarker = path.join(dataHome, 'setup-started'); + const setupScript = path.join(dataHome, 'setup-phase.cjs'); + process.env.XDG_DATA_HOME = dataHome; + + fs.writeFileSync( + setupScript, + `require('node:fs').writeFileSync(${JSON.stringify(setupMarker)}, 'started'); setTimeout(() => {}, 1000);\n`, + ); + + try { + const repo = 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 created = await createWorktree(repo, { + mode: 'new', + branchName: 'feature/bootstrap-phases', + worktreeName: 'bootstrap-phases', + returnAfterDirectoryCreated: true, + startCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(setupScript)}`, + }); + + expect(created.bootstrapStatus).toMatchObject({ + status: 'pending', + phase: 'directory-created', + error: null, + }); + + await expect.poll(() => fs.existsSync(setupMarker), { timeout: 5_000 }).toBe(true); + await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({ + status: 'pending', + phase: 'git-ready', + error: null, + }); + + await expect.poll( + async () => (await getWorktreeBootstrapStatus(created.path)).phase, + { timeout: 5_000 }, + ).toBe('setup-ready'); + await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({ + status: 'ready', + phase: 'setup-ready', + error: null, + }); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); + + it('waits for active bootstrap work before removing a worktree', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + const setupStarted = path.join(dataHome, 'remove-race-started'); + const setupCompleted = path.join(dataHome, 'remove-race-completed'); + const setupScript = path.join(dataHome, 'remove-race.cjs'); + process.env.XDG_DATA_HOME = dataHome; + + fs.writeFileSync( + setupScript, + `const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(setupStarted)}, 'started'); setTimeout(() => fs.writeFileSync(${JSON.stringify(setupCompleted)}, 'completed'), 300);\n`, + ); + + try { + const repo = 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 created = await createWorktree(repo, { + mode: 'new', + branchName: 'feature/remove-bootstrap-race', + worktreeName: 'remove-bootstrap-race', + returnAfterDirectoryCreated: true, + startCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(setupScript)}`, + }); + + await expect.poll(() => fs.existsSync(setupStarted), { timeout: 5_000 }).toBe(true); + let removalCompleted = false; + const removal = removeWorktree(repo, { directory: created.path }).then(() => { + removalCompleted = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(removalCompleted).toBe(false); + await removal; + + expect(fs.existsSync(setupCompleted)).toBe(true); + expect(fs.existsSync(created.path)).toBe(false); + await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({ + status: 'ready', + phase: 'setup-ready', + }); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); + it('recovers from an unchanged stale index lock while populating a worktree', async () => { if (!canRunGit()) return;