feat: move sessions to new worktrees

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

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

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

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

Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
This commit is contained in:
Bohdan Triapitsyn
2026-07-19 00:00:31 +03:00
parent e9d93a6744
commit 3fd6627196
31 changed files with 1322 additions and 151 deletions
@@ -112,6 +112,7 @@ export const iconSpriteData = {
"folder-open-fill": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H4V18.996L6 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3Z" fill="currentColor"/>`, "folder-open-fill": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H4V18.996L6 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3Z" fill="currentColor"/>`,
"folder-open": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H19V7H11.5858L9.58579 5H4V16.998L5.5 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3ZM19.9384 13H7.06155L5.56155 19H18.4384L19.9384 13Z" fill="currentColor"/>`, "folder-open": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H19V7H11.5858L9.58579 5H4V16.998L5.5 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3ZM19.9384 13H7.06155L5.56155 19H18.4384L19.9384 13Z" fill="currentColor"/>`,
"folder-received": `<path d="M22 13H20V7H11.5858L9.58579 5H4V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H21C21.5523 5 22 5.44772 22 6V13ZM20 17H23V19H20V22.5L15 18L20 13.5V17Z" fill="currentColor"/>`, "folder-received": `<path d="M22 13H20V7H11.5858L9.58579 5H4V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H21C21.5523 5 22 5.44772 22 6V13ZM20 17H23V19H20V22.5L15 18L20 13.5V17Z" fill="currentColor"/>`,
"folder-shared": `<path d="M22 13H20V7H11.5858L9.58579 5H4V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H21C21.5523 5 22 5.44772 22 6V13ZM18 17V13.5L23 18L18 22.5V19H15V17H18Z" fill="currentColor"/>`,
"folders": `<path d="M6 7V4C6 3.44772 6.44772 3 7 3H13.4142L15.4142 5H21C21.5523 5 22 5.44772 22 6V16C22 16.5523 21.5523 17 21 17H18V20C18 20.5523 17.5523 21 17 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H6ZM6 9H4V19H16V17H6V9ZM8 5V15H20V7H14.5858L12.5858 5H8Z" fill="currentColor"/>`, "folders": `<path d="M6 7V4C6 3.44772 6.44772 3 7 3H13.4142L15.4142 5H21C21.5523 5 22 5.44772 22 6V16C22 16.5523 21.5523 17 21 17H18V20C18 20.5523 17.5523 21 17 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H6ZM6 9H4V19H16V17H6V9ZM8 5V15H20V7H14.5858L12.5858 5H8Z" fill="currentColor"/>`,
"fullscreen-exit": `<path d="M18 7H22V9H16V3H18V7ZM8 9H2V7H6V3H8V9ZM18 17V21H16V15H22V17H18ZM8 15V21H6V17H2V15H8Z" fill="currentColor"/>`, "fullscreen-exit": `<path d="M18 7H22V9H16V3H18V7ZM8 9H2V7H6V3H8V9ZM18 17V21H16V15H22V17H18ZM8 15V21H6V17H2V15H8Z" fill="currentColor"/>`,
"fullscreen": `<path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z" fill="currentColor"/>`, "fullscreen": `<path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z" fill="currentColor"/>`,
@@ -9,6 +9,7 @@
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills. - 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. - 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. - 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: - New extractions in latest pass reduced local effect/callback bulk further:
- project session list builders - project session list builders
- folder cleanup sync - folder cleanup sync
@@ -42,6 +42,7 @@ import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
import { FusionIcon } from '@/components/icons/FusionIcon'; import { FusionIcon } from '@/components/icons/FusionIcon';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
type Folder = { id: string; name: string; sessionIds: string[] }; type Folder = { id: string; name: string; sessionIds: string[] };
@@ -346,6 +347,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
return out; 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 [exportDialogOpen, setExportDialogOpen] = React.useState(false);
const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true); 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]), React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
); );
const sessionStatus = useGlobalSessionStatus(session.id); const sessionStatus = useGlobalSessionStatus(session.id);
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
const sessionGoal = getSessionGoal(resolvedSession); const sessionGoal = getSessionGoal(resolvedSession);
const sessionGoalGlyph = sessionGoal ? ( const sessionGoalGlyph = sessionGoal ? (
@@ -578,7 +592,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const statusType = sessionStatus?.type ?? 'idle'; const statusType = sessionStatus?.type ?? 'idle';
const isStreaming = statusType === 'busy' || statusType === 'retry'; const isStreaming = statusType === 'busy' || statusType === 'retry';
const pendingPermissionCount = sessionPermissions.length; const pendingPermissionCount = sessionPermissions.length;
const showUnreadStatus = !isStreaming && needsAttention && !isActive; const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus; const showStatusMarker = isStreaming || showUnreadStatus;
const statusMarkerContent = isStreaming const statusMarkerContent = isStreaming
? ( ? (
@@ -595,8 +609,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
title={t('sessions.sidebar.session.status.unread')} title={t('sessions.sidebar.session.status.unread')}
/> />
); );
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (showStatusMarker || isPinnedSession); const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession);
const showPinnedMarker = isPinnedSession && !showStatusMarker; const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker;
const pinnedMarkerContent = ( const pinnedMarkerContent = (
<Icon <Icon
name="pushpin" name="pushpin"
@@ -604,7 +618,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
aria-label={t('sessions.sidebar.session.status.pinned')} aria-label={t('sessions.sidebar.session.status.pinned')}
/> />
); );
const leadingIndicators = showStatusMarker || showPinnedMarker ? ( const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? (
<span <span
className={cn( className={cn(
'pointer-events-none absolute left-0.5 inline-flex h-3.5 w-3.5 items-center justify-center transition-opacity', 'pointer-events-none absolute left-0.5 inline-flex h-3.5 w-3.5 items-center justify-center transition-opacity',
@@ -612,11 +626,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '', hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
)} )}
> >
{showStatusMarker ? statusMarkerContent : null} {isMovingToWorktree ? (
{showPinnedMarker ? pinnedMarkerContent : null} <Icon
name="loader-4"
className="h-3 w-3 animate-spin text-primary"
aria-label={t('sessions.sidebar.session.status.movingToWorktree')}
/>
) : showStatusMarker ? statusMarkerContent : showPinnedMarker ? pinnedMarkerContent : null}
</span> </span>
) : null; ) : null;
const hideChevronUntilHover = hasChildren && !alwaysShowActions && (showStatusMarker || isPinnedSession); const hideChevronUntilHover = hasChildren && !alwaysShowActions && (isMovingToWorktree || showStatusMarker || isPinnedSession);
const subsessionChevron = hasChildren ? ( const subsessionChevron = hasChildren ? (
<span <span
role="button" role="button"
@@ -839,6 +858,38 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="download" className="mr-1 h-4 w-4" /> <Icon name="download" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.exportMarkdown')} {t('sessions.sidebar.session.menu.exportMarkdown')}
</Item> </Item>
{!isSubtaskSession && !archivedBucket && !isVSCode ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<Item
disabled={!sessionDirectory || isStreaming || isMovingToWorktree}
onClick={() => {
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"
>
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.moveToWorktree')}
</Item>
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-72">
{isMovingToWorktree
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
: isStreaming
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
</TooltipContent>
</Tooltip>
) : null}
{isMultiRunLikeSession ? ( {isMultiRunLikeSession ? (
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1"> <Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
<FusionIcon className="mr-1 h-4 w-4" /> <FusionIcon className="mr-1 h-4 w-4" />
+1
View File
@@ -357,6 +357,7 @@ export interface GitWorktreeValidationResult {
export interface GitWorktreeBootstrapStatus { export interface GitWorktreeBootstrapStatus {
status: 'pending' | 'ready' | 'failed'; status: 'pending' | 'ready' | 'failed';
phase?: 'directory-created' | 'git-ready' | 'setup-ready';
error: string | null; error: string | null;
updatedAt: number; updatedAt: number;
} }
+7
View File
@@ -456,6 +456,12 @@ export const dict = {
'sessions.sidebar.session.menu.copyLink': 'Copy link', 'sessions.sidebar.session.menu.copyLink': 'Copy link',
'sessions.sidebar.session.menu.unshare': 'Unshare', 'sessions.sidebar.session.menu.unshare': 'Unshare',
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown', '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.runFusion': 'Run fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel', 'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor', '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.active': 'Session active',
'sessions.sidebar.session.status.unread': 'Unread updates', 'sessions.sidebar.session.status.unread': 'Unread updates',
'sessions.sidebar.session.status.pinned': 'Pinned session', '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.status.permissionRequired': 'Permission required',
'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions', 'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions',
'sessions.sidebar.session.subsessions.expand': 'Expand subsessions', 'sessions.sidebar.session.subsessions.expand': 'Expand subsessions',
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.copyLink": "Copiar enlace", "sessions.sidebar.session.menu.copyLink": "Copiar enlace",
"sessions.sidebar.session.menu.unshare": "Dejar de compartir", "sessions.sidebar.session.menu.unshare": "Dejar de compartir",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown", "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.runFusion": "Ejecutar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral", "sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir en el editor", "sessions.sidebar.session.actions.openInEditor": "Abrir en el editor",
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.active": "Sesión activa", "sessions.sidebar.session.status.active": "Sesión activa",
"sessions.sidebar.session.status.unread": "Actualizaciones no leídas", "sessions.sidebar.session.status.unread": "Actualizaciones no leídas",
"sessions.sidebar.session.status.pinned": "Sesión anclada", "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.status.permissionRequired": "Permiso requerido",
"sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones", "sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones",
"sessions.sidebar.session.subsessions.expand": "Expandir subsesiones", "sessions.sidebar.session.subsessions.expand": "Expandir subsesiones",
+7
View File
@@ -298,6 +298,12 @@ export const dict = {
'sessions.sidebar.session.menu.copyLink': 'Copier le lien', 'sessions.sidebar.session.menu.copyLink': 'Copier le lien',
'sessions.sidebar.session.menu.unshare': 'Annuler le partage', 'sessions.sidebar.session.menu.unshare': 'Annuler le partage',
'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown', '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.runFusion': 'Exécuter la fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral', 'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral',
'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur', '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.active': 'Session active',
'sessions.sidebar.session.status.unread': 'Mises à jour non lues', 'sessions.sidebar.session.status.unread': 'Mises à jour non lues',
'sessions.sidebar.session.status.pinned': 'Session épinglée', '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.status.permissionRequired': 'Autorisation requise',
'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions', 'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions',
'sessions.sidebar.session.subsessions.expand': 'Développer les sous-sessions', 'sessions.sidebar.session.subsessions.expand': 'Développer les sous-sessions',
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': 'リンクをコピー', 'sessions.sidebar.session.menu.copyLink': 'リンクをコピー',
'sessions.sidebar.session.menu.unshare': '共有解除', 'sessions.sidebar.session.menu.unshare': '共有解除',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート', '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.runFusion': 'フュージョンを実行',
'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く', 'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く',
'sessions.sidebar.session.actions.openInEditor': 'エディターで開く', 'sessions.sidebar.session.actions.openInEditor': 'エディターで開く',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': 'セッションアクティブ', 'sessions.sidebar.session.status.active': 'セッションアクティブ',
'sessions.sidebar.session.status.unread': '未読の更新', 'sessions.sidebar.session.status.unread': '未読の更新',
'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション', 'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション',
'sessions.sidebar.session.status.movingToWorktree': 'セッションを新しいworktreeへ移動中',
'sessions.sidebar.session.status.permissionRequired': '権限が必要です', 'sessions.sidebar.session.status.permissionRequired': '権限が必要です',
'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ', 'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ',
'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開', 'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開',
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': '링크 복사', 'sessions.sidebar.session.menu.copyLink': '링크 복사',
'sessions.sidebar.session.menu.unshare': '공유 해제', 'sessions.sidebar.session.menu.unshare': '공유 해제',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기', '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.runFusion': 'fusion 실행',
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기', 'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기', 'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': '활성 세션', 'sessions.sidebar.session.status.active': '활성 세션',
'sessions.sidebar.session.status.unread': '읽지 않은 업데이트', 'sessions.sidebar.session.status.unread': '읽지 않은 업데이트',
'sessions.sidebar.session.status.pinned': '고정된 세션', 'sessions.sidebar.session.status.pinned': '고정된 세션',
'sessions.sidebar.session.status.movingToWorktree': '세션을 새 worktree로 이동하는 중',
'sessions.sidebar.session.status.permissionRequired': '권한 필요', 'sessions.sidebar.session.status.permissionRequired': '권한 필요',
'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기', 'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기',
'sessions.sidebar.session.subsessions.expand': '하위 세션 펼치기', 'sessions.sidebar.session.subsessions.expand': '하위 세션 펼치기',
+7
View File
@@ -262,6 +262,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': 'Kopiuj link', 'sessions.sidebar.session.menu.copyLink': 'Kopiuj link',
'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie', 'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie',
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown', '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.runFusion': 'Uruchom fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym', 'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze', 'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': 'Sesja aktywna', 'sessions.sidebar.session.status.active': 'Sesja aktywna',
'sessions.sidebar.session.status.unread': 'Nieprzeczytane aktualizacje', 'sessions.sidebar.session.status.unread': 'Nieprzeczytane aktualizacje',
'sessions.sidebar.session.status.pinned': 'Przypięta sesja', '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.status.permissionRequired': 'Wymagane uprawnienie',
'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje', 'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje',
'sessions.sidebar.session.subsessions.expand': 'Rozwiń pod-sesje', 'sessions.sidebar.session.subsessions.expand': 'Rozwiń pod-sesje',
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.copyLink": "Copiar link", "sessions.sidebar.session.menu.copyLink": "Copiar link",
"sessions.sidebar.session.menu.unshare": "Parar de compartilhar", "sessions.sidebar.session.menu.unshare": "Parar de compartilhar",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown", "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.runFusion": "Executar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral", "sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor", "sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.active": "Sessão ativa", "sessions.sidebar.session.status.active": "Sessão ativa",
"sessions.sidebar.session.status.unread": "Atualizações não lidas", "sessions.sidebar.session.status.unread": "Atualizações não lidas",
"sessions.sidebar.session.status.pinned": "Sessão fixada", "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.status.permissionRequired": "Permissão obrigatória",
"sessions.sidebar.session.subsessions.collapse": "Recolher subsessões", "sessions.sidebar.session.subsessions.collapse": "Recolher subsessões",
"sessions.sidebar.session.subsessions.expand": "Expandir subsessões", "sessions.sidebar.session.subsessions.expand": "Expandir subsessões",
+7
View File
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.copyLink": "Копіювати посилання", "sessions.sidebar.session.menu.copyLink": "Копіювати посилання",
"sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ", "sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ",
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown", "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.runFusion": "Запустити fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі", "sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі", "sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.status.active": "Сесія активний", "sessions.sidebar.session.status.active": "Сесія активний",
"sessions.sidebar.session.status.unread": "Непрочитані оновлення", "sessions.sidebar.session.status.unread": "Непрочитані оновлення",
"sessions.sidebar.session.status.pinned": "Закріплений сесія", "sessions.sidebar.session.status.pinned": "Закріплений сесія",
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл", "sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії", "sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії", "sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': '复制链接', 'sessions.sidebar.session.menu.copyLink': '复制链接',
'sessions.sidebar.session.menu.unshare': '取消分享', 'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown', '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.runFusion': '运行融合',
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开', 'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开', 'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': '会话活跃中', 'sessions.sidebar.session.status.active': '会话活跃中',
'sessions.sidebar.session.status.unread': '有未读更新', 'sessions.sidebar.session.status.unread': '有未读更新',
'sessions.sidebar.session.status.pinned': '已置顶会话', 'sessions.sidebar.session.status.pinned': '已置顶会话',
'sessions.sidebar.session.status.movingToWorktree': '正在将会话移至新工作树',
'sessions.sidebar.session.status.permissionRequired': '需要权限', 'sessions.sidebar.session.status.permissionRequired': '需要权限',
'sessions.sidebar.session.subsessions.collapse': '折叠子会话', 'sessions.sidebar.session.subsessions.collapse': '折叠子会话',
'sessions.sidebar.session.subsessions.expand': '展开子会话', 'sessions.sidebar.session.subsessions.expand': '展开子会话',
@@ -470,6 +470,12 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.copyLink': '複製連結', 'sessions.sidebar.session.menu.copyLink': '複製連結',
'sessions.sidebar.session.menu.unshare': '取消分享', 'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown', '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.runFusion': '執行 fusion',
'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟', 'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟',
'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟', 'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟',
@@ -490,6 +496,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.status.active': '會話活躍中', 'sessions.sidebar.session.status.active': '會話活躍中',
'sessions.sidebar.session.status.unread': '有未讀更新', 'sessions.sidebar.session.status.unread': '有未讀更新',
'sessions.sidebar.session.status.pinned': '已釘選會話', 'sessions.sidebar.session.status.pinned': '已釘選會話',
'sessions.sidebar.session.status.movingToWorktree': '正在將會話移至新工作樹',
'sessions.sidebar.session.status.permissionRequired': '需要權限', 'sessions.sidebar.session.status.permissionRequired': '需要權限',
'sessions.sidebar.session.subsessions.collapse': '摺疊子會話', 'sessions.sidebar.session.subsessions.collapse': '摺疊子會話',
'sessions.sidebar.session.subsessions.expand': '展開子會話', 'sessions.sidebar.session.subsessions.expand': '展開子會話',
+42 -28
View File
@@ -26,8 +26,8 @@ import {
resolvePendingDraftWorktreeRequest, resolvePendingDraftWorktreeRequest,
} from '@/lib/worktrees/pendingDraftWorktree'; } from '@/lib/worktrees/pendingDraftWorktree';
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
import { normalizePath } from '@/lib/pathNormalization';
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value; import { resolveProjectForDirectory } from '@/lib/projectResolution';
const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory: string): Promise<void> => { const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory: string): Promise<void> => {
if (await getWorktreeSetupWaitEnabled(project)) { if (await getWorktreeSetupWaitEnabled(project)) {
@@ -35,29 +35,51 @@ const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory:
} }
}; };
const resolveProjectRef = (directory: string): ProjectRef | null => { export const resolveProjectRef = (directory: string): ProjectRef | null => {
const normalized = normalizePath(directory);
const projects = useProjectsStore.getState().projects; const projects = useProjectsStore.getState().projects;
if (projects.length === 0) { const normalizedDirectory = normalizePath(directory);
return null; if (!normalizedDirectory) return null;
}
const activeProject = useProjectsStore.getState().getActiveProject(); let project: (typeof projects)[number] | null = null;
if (activeProject?.path) { let matchedWorktreePathLength = -1;
const activePath = normalizePath(activeProject.path); for (const [projectPath, worktrees] of useSessionUIStore.getState().availableWorktreesByProject) {
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) { for (const worktree of worktrees) {
return { id: activeProject.id, path: activeProject.path }; 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) => { project ??= resolveProjectForDirectory(projects, normalizedDirectory);
const projectPath = normalizePath(project.path); return project ? { id: project.id, path: project.path } : null;
return normalized === projectPath || normalized.startsWith(`${projectPath}/`); };
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 // Track if a worktree creation flow is already running
@@ -233,15 +255,7 @@ const createInstantWorktreeDraft = async (options?: {
useDirectoryStore.getState().setDirectory(preview.path, { showOverlay: false }); useDirectoryStore.getState().setDirectory(preview.path, { showOverlay: false });
} }
const setupCommands = await getWorktreeSetupCommands(projectRef); const metadata = await createQuickWorktree(projectRef, { preferredName });
const metadata = await createWorktreeWithDefaults(projectRef, {
preferredName,
mode: 'new',
branchName: preferredName,
worktreeName: preferredName,
setupCommands,
returnAfterDirectoryCreated: true,
});
resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path); resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path);
useSessionUIStore.getState().overrideNewSessionDraftTarget({ useSessionUIStore.getState().overrideNewSessionDraftTarget({
@@ -0,0 +1,180 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { getGitStatus } from '@/lib/gitApi';
import { normalizePath } from '@/lib/pathNormalization';
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
import { refreshGlobalSessionsForDirectories } from '@/stores/useGlobalSessionsStore';
import { moveSessionToDirectory } from '@/sync/session-actions';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getDirectoryState } from '@/sync/sync-refs';
import type { WorktreeMetadata } from '@/types/worktree';
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
import { create } from 'zustand';
const useSessionMoveState = create<{ pendingSessionIds: Set<string> }>(() => ({
pendingSessionIds: new Set(),
}));
export const useIsSessionWorktreeMovePending = (sessionId: string): boolean =>
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId));
const setSessionMovePending = (sessionId: string, pending: boolean): void => {
useSessionMoveState.setState((state) => {
if (state.pendingSessionIds.has(sessionId) === pending) return state;
const pendingSessionIds = new Set(state.pendingSessionIds);
if (pending) pendingSessionIds.add(sessionId);
else pendingSessionIds.delete(sessionId);
return { pendingSessionIds };
});
};
const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise<string> => {
try {
const status = await getGitStatus(directory, { mode: 'light' });
const currentBranch = status.current?.trim();
if (currentBranch) return currentBranch;
} catch {
// Fall back to discovered worktree metadata below.
}
const normalizedDirectory = normalizePath(directory);
const normalizedProjectDirectory = normalizePath(projectDirectory) ?? projectDirectory;
const worktrees = useSessionUIStore.getState().availableWorktreesByProject;
const metadata = (worktrees.get(normalizedProjectDirectory) ?? worktrees.get(projectDirectory) ?? [])
.find((worktree) => normalizePath(worktree.path) === normalizedDirectory);
const mappedBranch = metadata?.branch?.trim();
if (mappedBranch) return mappedBranch;
throw new Error('Unable to determine the current branch');
};
const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void => {
const directoryState = getDirectoryState(sourceDirectory);
if (!directoryState) throw new Error('Session status is unavailable');
const statuses = directoryState.session_status;
const hasActiveSession = sessions.some((session) => {
const status = statuses[session.id]?.type;
return status === 'busy' || status === 'retry';
});
if (hasActiveSession) throw new Error('Session is not idle');
};
const rollbackMovedSessions = async (
sessions: Session[],
rootSessionId: string,
sourceDirectory: string,
worktreeDirectory: string,
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
): Promise<unknown[]> => {
const failures: unknown[] = [];
for (const session of [...sessions].reverse()) {
try {
await moveSessionToDirectory(
session,
worktreeDirectory,
sourceDirectory,
session.id === rootSessionId,
);
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
} catch (error) {
failures.push(error);
}
}
return failures;
};
const removeFailedWorktree = async (
project: ProjectRef,
worktree: WorktreeMetadata,
moveError: unknown,
): Promise<never> => {
try {
await removeProjectWorktree(project, worktree, { deleteLocalBranch: true });
} catch {
const message = moveError instanceof Error ? moveError.message : String(moveError);
throw new Error(`Session move failed and the new worktree could not be removed: ${message}`);
}
throw moveError;
};
const moveSessionTreeToQuickWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
}): Promise<string> => {
if (useSessionMoveState.getState().pendingSessionIds.has(input.root.id)) {
throw new Error('Session move already in progress');
}
setSessionMovePending(input.root.id, true);
try {
const project = resolveProjectRef(input.sourceDirectory);
if (!project) throw new Error('Unable to find the project for this session');
const sessions = [input.root, ...input.descendants];
const previousMetadata = new Map(
sessions.map((session) => [
session.id,
useSessionUIStore.getState().getWorktreeMetadata(session.id),
]),
);
assertSessionsIdle(sessions, input.sourceDirectory);
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
const moved: Session[] = [];
try {
await waitForWorktreeGitReady(worktree.path);
// Branch/status discovery and worktree creation can take long enough for a
// session to start running, so verify the whole tree again before moving.
assertSessionsIdle(sessions, input.sourceDirectory);
for (const [index, session] of sessions.entries()) {
// Transfer the checkout changes once with the root. Descendants only
// need their execution location updated.
await moveSessionToDirectory(session, input.sourceDirectory, worktree.path, index === 0);
moved.push(session);
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree));
}
} catch (error) {
const rollbackFailures = await rollbackMovedSessions(
moved,
input.root.id,
input.sourceDirectory,
worktree.path,
previousMetadata,
);
if (rollbackFailures.length > 0) {
throw new Error(`Session move partially failed and could not be fully rolled back: ${error instanceof Error ? error.message : String(error)}`);
}
return removeFailedWorktree(project, worktree, error);
}
try {
await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]);
} catch (error) {
// Direct action updates already reconciled both stores. Keep the move
// successful if this best-effort authoritative refresh is unavailable.
console.warn('[session-worktree-move] Failed to refresh moved sessions', error);
}
return worktree.path;
} finally {
setSessionMovePending(input.root.id, false);
}
};
export const startSessionTreeWorktreeMove = (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
successMessage: string;
failureMessage: string;
}): void => {
void moveSessionTreeToQuickWorktree(input)
.then(() => toast.success(input.successMessage))
.catch((error) => toast.error(input.failureMessage, {
description: error instanceof Error ? error.message : String(error),
}));
};
@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
const bootstrapStatusCalls: string[] = []; const bootstrapStatusCalls: string[] = [];
let bootstrapStatusResult: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number } = { let bootstrapStatusResult: GitWorktreeBootstrapStatus = {
status: 'ready', status: 'ready',
error: null, error: null,
updatedAt: 1, updatedAt: 1,
}; };
let getBootstrapStatus = (): Promise<GitWorktreeBootstrapStatus> => Promise.resolve(bootstrapStatusResult);
const toastErrors: Array<{ title: string; description?: string }> = []; const toastErrors: Array<{ title: string; description?: string }> = [];
mock.module('@/components/ui', () => ({ mock.module('@/components/ui', () => ({
@@ -29,7 +31,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
worktree: { worktree: {
bootstrapStatus: (directory: string) => { bootstrapStatus: (directory: string) => {
bootstrapStatusCalls.push(directory); bootstrapStatusCalls.push(directory);
return Promise.resolve(bootstrapStatusResult); return getBootstrapStatus();
}, },
}, },
}, },
@@ -39,7 +41,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
mock.module('@/lib/gitApiHttp', () => ({ mock.module('@/lib/gitApiHttp', () => ({
getGitWorktreeBootstrapStatus: (directory: string) => { getGitWorktreeBootstrapStatus: (directory: string) => {
bootstrapStatusCalls.push(directory); bootstrapStatusCalls.push(directory);
return Promise.resolve(bootstrapStatusResult); return getBootstrapStatus();
}, },
})); }));
@@ -47,8 +49,10 @@ const {
clearWorktreeBootstrapState, clearWorktreeBootstrapState,
getWorktreeBootstrapState, getWorktreeBootstrapState,
markWorktreeBootstrapPending, markWorktreeBootstrapPending,
setWorktreeBootstrapState,
startWorktreeBootstrapWatcher, startWorktreeBootstrapWatcher,
waitForWorktreeBootstrap, waitForWorktreeBootstrap,
waitForWorktreeGitReady,
} = await import('./worktreeBootstrap'); } = await import('./worktreeBootstrap');
const waitFor = async (predicate: () => boolean): Promise<void> => { const waitFor = async (predicate: () => boolean): Promise<void> => {
@@ -64,6 +68,7 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
bootstrapStatusCalls.length = 0; bootstrapStatusCalls.length = 0;
toastErrors.length = 0; toastErrors.length = 0;
bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 }; bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 };
getBootstrapStatus = () => Promise.resolve(bootstrapStatusResult);
clearWorktreeBootstrapState('/repo'); clearWorktreeBootstrapState('/repo');
clearWorktreeBootstrapState('/repo-wt'); clearWorktreeBootstrapState('/repo-wt');
}); });
@@ -82,6 +87,108 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
expect(bootstrapStatusCalls).toEqual(['/repo-wt']); 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 () => { test('background watcher polls pending worktrees without blocking', async () => {
markWorktreeBootstrapPending('/repo-wt'); markWorktreeBootstrapPending('/repo-wt');
const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = []; const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
@@ -14,10 +14,66 @@ const POLL_INTERVAL_MS = 250;
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value; const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
const state = new Map<string, WorktreeBootstrapState>(); const state = new Map<string, WorktreeBootstrapState>();
type WorktreeBootstrapTarget = 'git-ready' | 'setup-ready';
const waiters = new Map<string, Promise<void>>(); const waiters = new Map<string, Promise<void>>();
const watchers = new Map<string, { cancelled: boolean; promise: Promise<void> }>(); const lifecycleVersions = new Map<string, number>();
let nextLifecycleVersion = 0;
const watchers = new Map<string, { cancelled: boolean; lifecycleVersion: number }>();
const getKey = (directory: string): string => normalizePath(directory); const getKey = (directory: string): string => normalizePath(directory);
const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`;
const startLifecycle = (key: string): void => {
const watcher = watchers.get(key);
if (watcher) {
watcher.cancelled = true;
watchers.delete(key);
}
waiters.delete(getWaiterKey(key, 'git-ready'));
waiters.delete(getWaiterKey(key, 'setup-ready'));
const version = ++nextLifecycleVersion;
lifecycleVersions.set(key, version);
};
const isCurrentLifecycle = (key: string, version: number): boolean => lifecycleVersions.get(key) === version;
const phaseRank = (phase: GitWorktreeBootstrapStatus['phase']): number => {
switch (phase) {
case 'setup-ready':
return 2;
case 'git-ready':
return 1;
case 'directory-created':
default:
return 0;
}
};
const storePolledState = (
key: string,
next: WorktreeBootstrapState,
lifecycleVersion: number,
): WorktreeBootstrapState | null => {
if (!isCurrentLifecycle(key, lifecycleVersion)) {
return null;
}
const current = state.get(key);
const wouldRegressReadyState = current?.status === 'ready' && next.status === 'pending';
const wouldRegressPendingPhase = current?.status === 'pending'
&& next.status === 'pending'
&& phaseRank(next.phase) < phaseRank(current.phase);
if (wouldRegressReadyState || wouldRegressPendingPhase) {
return current;
}
state.set(key, next);
return next;
};
const getGitWorktreeBootstrapStatus = async (directory: string): Promise<GitWorktreeBootstrapStatus> => { const getGitWorktreeBootstrapStatus = async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
const runtimeGit = getRegisteredRuntimeAPIs()?.git; const runtimeGit = getRegisteredRuntimeAPIs()?.git;
@@ -35,8 +91,10 @@ export const markWorktreeBootstrapPending = (directory: string): void => {
if (!key) { if (!key) {
return; return;
} }
startLifecycle(key);
state.set(key, { state.set(key, {
status: 'pending', status: 'pending',
phase: 'directory-created',
error: null, error: null,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
@@ -47,13 +105,9 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
if (!key) { if (!key) {
return; return;
} }
const watcher = watchers.get(key); startLifecycle(key);
if (watcher) {
watcher.cancelled = true;
watchers.delete(key);
}
state.delete(key); state.delete(key);
waiters.delete(key); lifecycleVersions.delete(key);
}; };
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => { export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
@@ -61,10 +115,8 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots
if (!key) { if (!key) {
return; return;
} }
startLifecycle(key);
state.set(key, next); state.set(key, next);
if (next.status !== 'pending') {
waiters.delete(key);
}
}; };
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => { export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
@@ -97,19 +149,34 @@ const markBootstrapFailed = (
return failed; return failed;
}; };
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => { const hasReachedTarget = (status: GitWorktreeBootstrapStatus, target: WorktreeBootstrapTarget): boolean => {
if (status.status === 'ready') return true;
if (target === 'git-ready' && (status.phase === 'git-ready' || status.phase === 'setup-ready')) return true;
return false;
};
const pollWorktreeBootstrapUntilSettled = async (
directory: string,
key: string,
lifecycleVersion: number,
timeoutMs: number,
target: WorktreeBootstrapTarget,
): Promise<void> => {
const startedAt = Date.now(); const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) { while (Date.now() - startedAt < timeoutMs) {
const result = await getGitWorktreeBootstrapStatus(directory); 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; return;
} }
if (result.status === 'failed') { if (current.status === 'failed') {
throw new Error(result.error || 'Worktree bootstrap failed'); throw new Error(current.error || 'Worktree bootstrap failed');
} }
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
@@ -121,7 +188,8 @@ const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: n
const pollWorktreeBootstrapInBackground = async ( const pollWorktreeBootstrapInBackground = async (
directory: string, directory: string,
watcher: { cancelled: boolean }, key: string,
watcher: { cancelled: boolean; lifecycleVersion: number },
timeoutMs: number, timeoutMs: number,
pollIntervalMs: number, pollIntervalMs: number,
onFailed?: WorktreeBootstrapFailureHandler, onFailed?: WorktreeBootstrapFailureHandler,
@@ -134,17 +202,20 @@ const pollWorktreeBootstrapInBackground = async (
if (watcher.cancelled) { if (watcher.cancelled) {
return; return;
} }
setWorktreeBootstrapState(directory, result); const current = storePolledState(key, result, watcher.lifecycleVersion);
if (!current) {
if (result.status === 'ready') {
onReady?.(result);
return; return;
} }
if (result.status === 'failed') { if (current.status === 'ready') {
onFailed?.(result); onReady?.(current);
return;
}
if (current.status === 'failed') {
onFailed?.(current);
toast.error(t('worktree.bootstrap.toast.failed'), { toast.error(t('worktree.bootstrap.toast.failed'), {
description: result.error || t('worktree.bootstrap.toast.failedDescription'), description: current.error || t('worktree.bootstrap.toast.failedDescription'),
}); });
return; return;
} }
@@ -183,9 +254,13 @@ export const startWorktreeBootstrapWatcher = (
return; return;
} }
const watcher = { cancelled: false, promise: Promise.resolve() }; const watcher = {
watcher.promise = pollWorktreeBootstrapInBackground( cancelled: false,
lifecycleVersion: lifecycleVersions.get(key) ?? 0,
};
void pollWorktreeBootstrapInBackground(
directory, directory,
key,
watcher, watcher,
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
options?.pollIntervalMs ?? POLL_INTERVAL_MS, options?.pollIntervalMs ?? POLL_INTERVAL_MS,
@@ -211,7 +286,11 @@ export const startWorktreeBootstrapWatcher = (
watchers.set(key, watcher); watchers.set(key, watcher);
}; };
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => { const waitForWorktreePhase = async (
directory: string,
target: WorktreeBootstrapTarget,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<void> => {
const key = getKey(directory); const key = getKey(directory);
if (!key) { if (!key) {
return; return;
@@ -222,21 +301,31 @@ export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DE
return; return;
} }
if (current?.status === 'ready') { if (hasReachedTarget(current, target)) {
return; return;
} }
if (current?.status === 'failed') { if (current?.status === 'failed') {
throw new Error(current.error || 'Worktree bootstrap 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) { if (existing) {
return existing; return existing;
} }
const pending = pollWorktreeBootstrapUntilSettled(directory, timeoutMs).finally(() => { const lifecycleVersion = lifecycleVersions.get(key) ?? 0;
waiters.delete(key); 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; return pending;
}; };
export const waitForWorktreeGitReady = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
waitForWorktreePhase(directory, 'git-ready', timeoutMs);
export const waitForWorktreeBootstrap = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
waitForWorktreePhase(directory, 'setup-ready', timeoutMs);
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { GitWorktreeCreateResult } from '@/lib/api/types';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
type WorktreeListEntry = { type WorktreeListEntry = {
@@ -11,16 +12,24 @@ type WorktreeListEntry = {
const listCalls: string[] = []; const listCalls: string[] = [];
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = []; const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
const createdWorktree = { const createdWorktree = {
head: 'abc123',
name: 'feature', name: 'feature',
branch: 'feature', branch: 'feature',
path: '/repo-feature', path: '/repo-feature',
directoryCreated: true as const, directoryCreated: true as const,
bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 }, bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 },
}; };
let createdWorktreeResult: GitWorktreeCreateResult = createdWorktree;
const bootstrapWatcherCalls: string[] = [];
const bootstrapWatcherOptions: Array<{ onReady?: () => void }> = [];
const sessionState = { const sessionState = {
availableWorktreesByProject: new Map<string, WorktreeMetadata[]>(), availableWorktreesByProject: new Map<string, WorktreeMetadata[]>(),
availableWorktrees: [] as WorktreeMetadata[], availableWorktrees: [] as WorktreeMetadata[],
worktreeMetadata: new Map<string, WorktreeMetadata>(),
};
const attachmentState = {
attachments: new Map<string, { worktreeStatus: 'pending' | 'ready'; worktreeRoot: string }>(),
}; };
mock.module('@/lib/openchamberConfig', () => ({ mock.module('@/lib/openchamberConfig', () => ({
@@ -31,7 +40,19 @@ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
clearWorktreeBootstrapState: mock(), clearWorktreeBootstrapState: mock(),
markWorktreeBootstrapPending: mock(), markWorktreeBootstrapPending: mock(),
setWorktreeBootstrapState: mock(), setWorktreeBootstrapState: mock(),
startWorktreeBootstrapWatcher: mock(), startWorktreeBootstrapWatcher: (directory: string, options?: { onReady?: () => void }) => {
bootstrapWatcherCalls.push(directory);
bootstrapWatcherOptions.push(options ?? {});
},
}));
mock.module('@/sync/session-worktree-store', () => ({
useSessionWorktreeStore: {
setState: (patch: Partial<typeof attachmentState> | ((state: typeof attachmentState) => Partial<typeof attachmentState>)) => {
const next = typeof patch === 'function' ? patch(attachmentState) : patch;
Object.assign(attachmentState, next);
},
},
})); }));
mock.module('@/lib/worktrees/worktreeStatus', () => ({ mock.module('@/lib/worktrees/worktreeStatus', () => ({
@@ -59,13 +80,13 @@ mock.module('@/lib/gitApi', () => ({
listResolvers.push(resolve); listResolvers.push(resolve);
}); });
}, },
create: mock(() => Promise.resolve(createdWorktree)), create: mock(() => Promise.resolve(createdWorktreeResult)),
remove: mock(() => Promise.resolve({ success: true })), remove: mock(() => Promise.resolve({ success: true })),
}, },
}, },
})); }));
const { createWorktree, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager'); const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
const waitForListCallCount = async (count: number): Promise<void> => { const waitForListCallCount = async (count: number): Promise<void> => {
for (let attempt = 0; attempt < 10; attempt += 1) { for (let attempt = 0; attempt < 10; attempt += 1) {
@@ -81,8 +102,13 @@ describe('worktreeManager list invalidation', () => {
beforeEach(() => { beforeEach(() => {
listCalls.length = 0; listCalls.length = 0;
listResolvers.length = 0; listResolvers.length = 0;
bootstrapWatcherCalls.length = 0;
bootstrapWatcherOptions.length = 0;
createdWorktreeResult = createdWorktree;
sessionState.availableWorktreesByProject = new Map(); sessionState.availableWorktreesByProject = new Map();
sessionState.availableWorktrees = []; 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 () => { 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(metadata.worktreeStatus).toBe('pending');
expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending'); expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending');
expect(bootstrapWatcherCalls).toEqual(['/repo-feature']);
});
test('treats legacy create responses without bootstrap state as fully ready', async () => {
createdWorktreeResult = {
head: '',
name: 'legacy-feature',
branch: 'legacy-feature',
path: '/repo-legacy-feature',
};
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'legacy-feature',
mode: 'new',
branchName: 'legacy-feature',
worktreeName: 'legacy-feature',
returnAfterDirectoryCreated: true,
});
expect(metadata.worktreeStatus).toBe('ready');
expect(bootstrapWatcherCalls).toEqual([]);
});
test('reconciles session attachments when bootstrap becomes ready', async () => {
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'feature',
mode: 'new',
branchName: 'feature',
worktreeName: 'feature',
returnAfterDirectoryCreated: true,
});
sessionState.worktreeMetadata.set('session-1', metadata);
attachmentState.attachments.set('session-1', {
worktreeRoot: metadata.path,
worktreeStatus: 'pending',
});
bootstrapWatcherOptions[0]?.onReady?.();
expect(sessionState.worktreeMetadata.get('session-1')?.worktreeStatus).toBe('ready');
expect(attachmentState.attachments.get('session-1')?.worktreeStatus).toBe('ready');
});
test('resolves ready metadata when bootstrap settles before the session is attached', async () => {
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'feature',
mode: 'new',
branchName: 'feature',
worktreeName: 'feature',
returnAfterDirectoryCreated: true,
});
bootstrapWatcherOptions[0]?.onReady?.();
expect(metadata.worktreeStatus).toBe('pending');
expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready');
}); });
}); });
@@ -17,6 +17,7 @@ import type {
GitWorktreeValidationResult, GitWorktreeValidationResult,
} from '@/lib/api/types'; } from '@/lib/api/types';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
type WorktreeListEntry = { type WorktreeListEntry = {
path?: string; path?: string;
@@ -57,6 +58,18 @@ const normalizePath = (value: string): string => {
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; 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 => { const slugifyWorktreeName = (value: string): string => {
return value return value
.trim() .trim()
@@ -86,6 +99,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
return; return;
} }
const changedSessionIds: string[] = [];
useSessionUIStore.setState((state) => { useSessionUIStore.setState((state) => {
let changed = false; let changed = false;
@@ -131,6 +145,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
for (const [sessionId, metadata] of state.worktreeMetadata) { for (const [sessionId, metadata] of state.worktreeMetadata) {
const next = applyStatus(metadata); const next = applyStatus(metadata);
if (next !== metadata) { if (next !== metadata) {
changedSessionIds.push(sessionId);
if (worktreeMetadata === state.worktreeMetadata) { if (worktreeMetadata === state.worktreeMetadata) {
worktreeMetadata = new Map(state.worktreeMetadata); worktreeMetadata = new Map(state.worktreeMetadata);
} }
@@ -148,6 +163,19 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
worktreeMetadata, worktreeMetadata,
}; };
}); });
if (changedSessionIds.length > 0) {
useSessionWorktreeStore.setState((state) => {
let attachments = state.attachments;
for (const sessionId of changedSessionIds) {
const attachment = attachments.get(sessionId);
if (!attachment || attachment.worktreeStatus === status) continue;
if (attachments === state.attachments) attachments = new Map(state.attachments);
attachments.set(sessionId, { ...attachment, worktreeStatus: status });
}
return attachments === state.attachments ? state : { attachments };
});
}
}; };
const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => { const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => {
@@ -404,13 +432,17 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
if (created?.bootstrapStatus) { if (created?.bootstrapStatus) {
setWorktreeBootstrapState(metadata.path, created.bootstrapStatus); setWorktreeBootstrapState(metadata.path, created.bootstrapStatus);
} else { } else if (created?.directoryCreated) {
markWorktreeBootstrapPending(metadata.path); markWorktreeBootstrapPending(metadata.path);
} }
startWorktreeBootstrapWatcher(metadata.path, { const shouldWatchBootstrap = created?.bootstrapStatus?.status === 'pending'
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'), || (!created?.bootstrapStatus && created?.directoryCreated === true);
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'), if (shouldWatchBootstrap) {
}); startWorktreeBootstrapWatcher(metadata.path, {
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
});
}
invalidateWorktreeList(projectDirectory); invalidateWorktreeList(projectDirectory);
// The new worktree changes the repo's worktree topology; drop cached root // The new worktree changes the repo's worktree topology; drop cached root
+4 -2
View File
@@ -100,8 +100,9 @@ VS Code does not run the server permission-auto-accept runtime. The extension ho
- title update - title update
- share - share
- unshare - unshare
- archive - archive
- delete - delete
- move to another worktree directory
- retention cleanup batch archive/delete - retention cleanup batch archive/delete
This keeps cold/global lists responsive without requiring a refetch after every change. 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)` - `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
- `archiveSession()` -> `archiveSessions([id], archivedAt)` - `archiveSession()` -> `archiveSessions([id], archivedAt)`
- `deleteSession()` -> `removeSessions([id])` - `deleteSession()` -> `removeSessions([id])`
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
## The golden rule ## The golden rule
@@ -13,6 +13,7 @@ let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?:
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] } let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
const globalUpsertedSessions: unknown[] = [] const globalUpsertedSessions: unknown[] = []
const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = []
const mockScopedClient = { const mockScopedClient = {
permission: { permission: {
@@ -40,6 +41,14 @@ const mockScopedClient = {
} }
const mockSdk = { const mockSdk = {
experimental: {
controlPlane: {
moveSession: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "controlPlane.moveSession", params })
return Promise.resolve({})
}),
},
},
session: { session: {
messages: mock((params: Record<string, unknown>) => { messages: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.messages", params }) replyCalls.push({ method: "session.messages", params })
@@ -102,6 +111,7 @@ mock.module("@/lib/opencode/client", () => ({
return mockScopedClient return mockScopedClient
}, },
getDirectory: () => "/test/project", getDirectory: () => "/test/project",
getSdkClient: () => mockSdk,
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
return Promise.resolve(true) return Promise.resolve(true)
@@ -147,6 +157,9 @@ mock.module("./session-ui-store", () => ({
if (sessionId === "session-b") return "/other/project" if (sessionId === "session-b") return "/other/project"
return null return null
}, },
setSessionDirectory: (sessionID: string, directory: string) => {
movedSessionDirectories.push({ sessionID, directory })
},
}), }),
}, },
})) }))
@@ -234,6 +247,87 @@ function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
} as unknown as import("./child-store").ChildStoreManager } 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", () => { describe("fetchMessagesForSession startup race", () => {
test("does not reject before sync action refs are initialized", async () => { test("does not reject before sync action refs are initialized", async () => {
const { fetchMessagesForSession } = await import("./session-actions") const { fetchMessagesForSession } = await import("./session-actions")
+109
View File
@@ -192,6 +192,115 @@ export function mirrorSessionIntoLiveStores(session: Session, directory?: string
updateLiveSession(session) updateLiveSession(session)
} }
function moveRecordEntries<T>(
source: Record<string, T>,
destination: Record<string, T>,
keys: Iterable<string>,
): { source: Record<string, T>; destination: Record<string, T> } {
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<void> {
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() { function dir() {
return _getDirectory() || undefined return _getDirectory() || undefined
} }
+5
View File
@@ -21,6 +21,11 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- `bridge-git-process-runtime.ts` - `bridge-git-process-runtime.ts`
- Git process execution and environment setup (`execGit`), including SSH agent socket resolution. - 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-fs-runtime.ts`
- Bridge handlers for filesystem-related message routes. - Bridge handlers for filesystem-related message routes.
- Uses shared FS helpers via injected dependencies. - Uses shared FS helpers via injected dependencies.
@@ -7,6 +7,8 @@ const gitService = {
cherryPick: mock(), cherryPick: mock(),
revertCommit: mock(), revertCommit: mock(),
resetToCommit: mock(), resetToCommit: mock(),
createWorktree: mock(),
getWorktreeBootstrapStatus: mock(),
}; };
mock.module('./gitService', () => gitService); mock.module('./gitService', () => gitService);
@@ -21,6 +23,8 @@ describe('bridge git runtime index mutations', () => {
gitService.cherryPick.mockReset(); gitService.cherryPick.mockReset();
gitService.revertCommit.mockReset(); gitService.revertCommit.mockReset();
gitService.resetToCommit.mockReset(); gitService.resetToCommit.mockReset();
gitService.createWorktree.mockReset();
gitService.getWorktreeBootstrapStatus.mockReset();
}); });
it('accepts legacy stage path payloads', async () => { it('accepts legacy stage path payloads', async () => {
@@ -109,4 +113,66 @@ describe('bridge git runtime index mutations', () => {
expect(gitService.revertCommit).not.toHaveBeenCalled(); expect(gitService.revertCommit).not.toHaveBeenCalled();
expect(gitService.resetToCommit).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,
}));
});
}); });
+96 -30
View File
@@ -14,11 +14,23 @@ import type { API as GitAPI, Repository, GitExtension, Status } from './git.d';
let gitApi: GitAPI | null = null; let gitApi: GitAPI | null = null;
let gitExtensionEnabled = false; let gitExtensionEnabled = false;
const worktreeBootstrapState = new Map<string, { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>();
type WorktreeBootstrapStatus = {
status: 'pending' | 'ready' | 'failed';
phase: 'directory-created' | 'git-ready' | 'setup-ready';
error: string | null;
updatedAt: number;
};
const worktreeBootstrapState = new Map<string, WorktreeBootstrapStatus>();
const activeWorktreeBootstrapTasks = new Map<string, Promise<unknown>>();
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const; const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
const WORKTREE_BOOTSTRAP_READY = 'ready' as const; const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
const WORKTREE_BOOTSTRAP_FAILED = 'failed' 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_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
@@ -30,16 +42,35 @@ const toBootstrapStateKey = (directory: string): string => {
return path.resolve(normalized); 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); const key = toBootstrapStateKey(directory);
if (!key) { if (!key) {
return; return null;
} }
worktreeBootstrapState.set(key, {
const state: WorktreeBootstrapStatus = {
status, status,
phase,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null, error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(), 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 => { const clearWorktreeBootstrapState = (directory: string): void => {
@@ -50,6 +81,36 @@ const clearWorktreeBootstrapState = (directory: string): void => {
worktreeBootstrapState.delete(key); worktreeBootstrapState.delete(key);
}; };
const trackWorktreeBootstrapTask = (directory: string, task: Promise<unknown>): 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<void> => {
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 execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
@@ -780,7 +841,7 @@ export interface GitWorktreeInfo {
branch: string; branch: string;
path: string; path: string;
directoryCreated?: true; directoryCreated?: true;
bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }; bootstrapStatus?: WorktreeBootstrapStatus;
} }
type WorktreeListEntry = { type WorktreeListEntry = {
@@ -1485,8 +1546,8 @@ const queueWorktreeBootstrap = (args: {
ensureRemoteUrl, ensureRemoteUrl,
startCommand, startCommand,
} = args; } = args;
setTimeout(() => { const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
const run = async () => { .then(async () => {
await populateWorktreeWithLockRecovery(directory); await populateWorktreeWithLockRecovery(directory);
if (setUpstream) { if (setUpstream) {
await applyUpstreamConfiguration({ await applyUpstreamConfiguration({
@@ -1502,21 +1563,18 @@ const queueWorktreeBootstrap = (args: {
console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error)); 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) => { await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error)); console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
}); });
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY); setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY);
}; })
.catch((error) => {
void run().catch((error) => { setWorktreeBootstrapFailure(directory, error);
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(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) => { 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 upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); const bootstrapStatus = setWorktreeBootstrapState(
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING, status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null, error: null,
updatedAt: Date.now(), 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)); 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 = setWorktreeBootstrapState(
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_PHASE_DIRECTORY_CREATED,
) ?? {
status: WORKTREE_BOOTSTRAP_PENDING, status: WORKTREE_BOOTSTRAP_PENDING,
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
error: null, error: null,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
@@ -1987,15 +2053,12 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch; : candidate.branch;
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => { const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
setWorktreeBootstrapState( setWorktreeBootstrapFailure(candidate.directory, error);
candidate.directory, await cleanupFailedFastWorktreeCreate(context, candidate);
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
void cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error)); console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error));
}); });
trackWorktreeBootstrapTask(candidate.directory, task);
return { return {
head: '', head: '',
@@ -2010,7 +2073,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
return attachGitWorktreeToCandidate(context, candidate, input); 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<WorktreeBootstrapStatus> {
const key = toBootstrapStateKey(directory); const key = toBootstrapStateKey(directory);
if (!key) { if (!key) {
throw new Error('Worktree directory is required'); throw new Error('Worktree directory is required');
@@ -2023,6 +2086,7 @@ export async function getWorktreeBootstrapStatus(directory: string): Promise<{ s
return { return {
status: WORKTREE_BOOTSTRAP_READY, status: WORKTREE_BOOTSTRAP_READY,
phase: WORKTREE_PHASE_SETUP_READY,
error: null, error: null,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
@@ -2034,6 +2098,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
throw new Error('Worktree directory is required'); throw new Error('Worktree directory is required');
} }
await waitForActiveWorktreeBootstrap(targetDirectory);
const context = await resolveWorktreeProjectContext(directory); const context = await resolveWorktreeProjectContext(directory);
const deleteLocalBranch = input?.deleteLocalBranch === true; const deleteLocalBranch = input?.deleteLocalBranch === true;
@@ -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,
});
});
});
+9 -8
View File
@@ -18,6 +18,7 @@ import type {
GeneratedCommitMessage, GeneratedCommitMessage,
GeneratedPullRequestDescription, GeneratedPullRequestDescription,
GitWorktreeInfo, GitWorktreeInfo,
GitWorktreeBootstrapStatus,
CreateGitWorktreePayload, CreateGitWorktreePayload,
GitWorktreeValidationResult, GitWorktreeValidationResult,
GitWorktreeCreateResult, GitWorktreeCreateResult,
@@ -41,6 +42,10 @@ import type {
ResetToCommitResponse, ResetToCommitResponse,
} from '@openchamber/ui/lib/api/types'; } from '@openchamber/ui/lib/api/types';
const requestWorktreeBootstrapStatus = (directory: string): Promise<GitWorktreeBootstrapStatus> => {
return sendBridgeMessage<GitWorktreeBootstrapStatus>('api:git/worktrees/bootstrap-status', { directory });
};
type GitIdentityStoreState = { type GitIdentityStoreState = {
profiles: GitIdentityProfile[]; profiles: GitIdentityProfile[];
}; };
@@ -193,10 +198,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({
}); });
}, },
getGitWorktreeBootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => { getGitWorktreeBootstrapStatus: async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', { return requestWorktreeBootstrapStatus(directory);
directory,
});
}, },
previewGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => { previewGitWorktree: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
@@ -514,10 +517,8 @@ export const createVSCodeGitAPI = (): GitAPI => ({
...(payload || {}), ...(payload || {}),
}); });
}, },
bootstrapStatus: async (directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> => { bootstrapStatus: async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
return sendBridgeMessage<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>('api:git/worktrees/bootstrap-status', { return requestWorktreeBootstrapStatus(directory);
directory,
});
}, },
preview: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => { preview: async (directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> => {
return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees/preview', { return sendBridgeMessage<GitWorktreeCreateResult>('api:git/worktrees/preview', {
+2 -1
View File
@@ -120,8 +120,9 @@ The following functions are internal helpers used by exported functions:
- `branch`: Local branch name. - `branch`: Local branch name.
- `path`: Absolute path to worktree directory. - `path`: Absolute path to worktree directory.
- `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues. - `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. - 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. - 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 ### Log Response
+84 -33
View File
@@ -12,6 +12,7 @@ const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null; let resolvedGitBinary = null;
const worktreeBootstrapState = new Map(); const worktreeBootstrapState = new Map();
const activeWorktreeBootstrapTasks = new Map();
const remoteExistenceCache = new Map(); const remoteExistenceCache = new Map();
const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i; 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'; 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_PENDING = 'pending';
const WORKTREE_BOOTSTRAP_READY = 'ready'; const WORKTREE_BOOTSTRAP_READY = 'ready';
const WORKTREE_BOOTSTRAP_FAILED = 'failed'; 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_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
@@ -32,16 +36,21 @@ const toBootstrapStateKey = (directory) => {
return path.resolve(normalized); 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); const key = toBootstrapStateKey(directory);
if (!key) { if (!key) {
return; return null;
} }
worktreeBootstrapState.set(key, { const state = createWorktreeBootstrapState(status, phase, error);
status, worktreeBootstrapState.set(key, state);
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null, return state;
updatedAt: Date.now(),
});
}; };
const clearWorktreeBootstrapState = (directory) => { const clearWorktreeBootstrapState = (directory) => {
@@ -52,6 +61,37 @@ const clearWorktreeBootstrapState = (directory) => {
worktreeBootstrapState.delete(key); 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) => { const isExecutableFile = (candidate) => {
if (typeof candidate !== 'string' || candidate.trim().length === 0) { if (typeof candidate !== 'string' || candidate.trim().length === 0) {
return false; return false;
@@ -1730,8 +1770,8 @@ const queueWorktreeBootstrap = (args) => {
ensureRemoteUrl, ensureRemoteUrl,
startCommand, startCommand,
} = args; } = args;
setTimeout(() => { const task = new Promise((resolve) => setTimeout(resolve, 0))
const run = async () => { .then(async () => {
await populateWorktreeWithLockRecovery(directory); await populateWorktreeWithLockRecovery(directory);
if (setUpstream) { if (setUpstream) {
await applyUpstreamConfiguration({ await applyUpstreamConfiguration({
@@ -1747,21 +1787,31 @@ const queueWorktreeBootstrap = (args) => {
console.warn('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error)); 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) => { await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error)); console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error));
}); });
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY); setWorktreeBootstrapState(
}; directory,
WORKTREE_BOOTSTRAP_READY,
void run().catch((error) => { WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
);
})
.catch((error) => {
setWorktreeBootstrapState( setWorktreeBootstrapState(
directory, directory,
WORKTREE_BOOTSTRAP_FAILED, WORKTREE_BOOTSTRAP_FAILED,
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
); );
console.warn('Worktree bootstrap task failed:', 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) => { 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 upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); const bootstrapStatus = setWorktreeBootstrapState(
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { candidate.directory,
status: WORKTREE_BOOTSTRAP_PENDING, WORKTREE_BOOTSTRAP_PENDING,
error: null, WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
updatedAt: Date.now(), );
};
queueWorktreeBootstrap({ queueWorktreeBootstrap({
directory: candidate.directory, 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)); console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
} }
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING); const bootstrapStatus = setWorktreeBootstrapState(
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? { candidate.directory,
status: WORKTREE_BOOTSTRAP_PENDING, WORKTREE_BOOTSTRAP_PENDING,
error: null, WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
updatedAt: Date.now(), );
};
const localBranch = mode === 'existing' const localBranch = mode === 'existing'
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch; : candidate.branch;
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => { const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
setWorktreeBootstrapState( setWorktreeBootstrapState(
candidate.directory, candidate.directory,
WORKTREE_BOOTSTRAP_FAILED, WORKTREE_BOOTSTRAP_FAILED,
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
error instanceof Error ? error.message : String(error) 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)); console.warn('Background worktree creation failed:', error instanceof Error ? error.message : String(error));
}); });
trackWorktreeBootstrapTask(candidate.directory, task);
return { return {
head: '', head: '',
@@ -3862,11 +3912,10 @@ export async function getWorktreeBootstrapStatus(directory) {
return current; return current;
} }
return { return createWorktreeBootstrapState(
status: WORKTREE_BOOTSTRAP_READY, WORKTREE_BOOTSTRAP_READY,
error: null, WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
updatedAt: Date.now(), );
};
} }
export async function removeWorktree(directory, input = {}) { export async function removeWorktree(directory, input = {}) {
@@ -3875,6 +3924,8 @@ export async function removeWorktree(directory, input = {}) {
throw new Error('Worktree directory is required'); throw new Error('Worktree directory is required');
} }
await waitForActiveWorktreeBootstrap(targetDirectory);
const context = await resolveWorktreeProjectContext(directory); const context = await resolveWorktreeProjectContext(directory);
const deleteLocalBranch = input?.deleteLocalBranch === true; const deleteLocalBranch = input?.deleteLocalBranch === true;
+130
View File
@@ -9,6 +9,7 @@ import {
checkoutCommit, checkoutCommit,
cherryPick, cherryPick,
createWorktree, createWorktree,
getWorktreeBootstrapStatus,
getStatus, getStatus,
populateWorktreeWithLockRecovery, populateWorktreeWithLockRecovery,
removeWorktree, removeWorktree,
@@ -322,6 +323,135 @@ describe('worktree root resolution', () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('createWorktree', () => { 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 () => { it('recovers from an unchanged stale index lock while populating a worktree', async () => {
if (!canRunGit()) return; if (!canRunGit()) return;