feat(sessions): move sessions to existing worktrees

This commit is contained in:
mattv8
2026-08-27 18:42:57 -06:00
parent b8465ae133
commit 9babd19d9b
29 changed files with 1967 additions and 98 deletions
+9
View File
@@ -2988,8 +2988,17 @@ export const dict = {
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
'sessions.sidebar.session.copyId.error': 'Sitzungs-ID konnte nicht kopiert werden',
'sessions.sidebar.session.menu.moveToWorktree': 'In neuen Worktree verschieben',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'In Worktree verschieben',
'sessions.sidebar.session.menu.newWorktree': 'Neuer Worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Sitzung in einen neuen Worktree verschoben',
'sessions.sidebar.session.moveToWorktree.failed': 'Sitzung konnte nicht in einen neuen Worktree verschoben werden',
'sessions.sidebar.session.moveToWorktree.main': 'Haupt-Worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktrees werden aktualisiert...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees konnten nicht geladen werden',
'sessions.sidebar.session.moveToWorktree.current': 'Aktueller Worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sitzung in Worktree verschoben',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Sitzung konnte nicht in Worktree verschoben werden',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Zeigt vorhandene Worktrees und die Option, für diese Sitzung einen neuen zu erstellen.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.',
+9
View File
@@ -536,8 +536,17 @@ export const dict = {
'sessions.sidebar.session.menu.unshare': 'Unshare',
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Move to new worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Move to worktree',
'sessions.sidebar.session.menu.newWorktree': '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.main': 'Main worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Refreshing worktrees...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees could not be loaded',
'sessions.sidebar.session.moveToWorktree.current': 'Current worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session moved to worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Failed to move session to worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Shows existing worktrees and the option to create a new one for this session.',
'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.',
+9
View File
@@ -537,8 +537,17 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Dejar de compartir",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover a un worktree nuevo",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover a worktree",
"sessions.sidebar.session.menu.newWorktree": "Nuevo worktree...",
"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.main": "Worktree principal",
"sessions.sidebar.session.moveToWorktree.refreshing": "Actualizando worktrees...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "No se pudieron cargar los worktrees",
"sessions.sidebar.session.moveToWorktree.current": "Worktree actual",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sesión movida al worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "No se pudo mover la sesión al worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Muestra los worktrees existentes y la opción de crear uno nuevo para esta sesión.",
"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.",
+9
View File
@@ -367,8 +367,17 @@ export const dict = {
'sessions.sidebar.session.menu.unshare': 'Annuler le partage',
'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Déplacer vers un nouveau worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Déplacer vers un worktree',
'sessions.sidebar.session.menu.newWorktree': '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.main': 'Worktree principal',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Actualisation des worktrees...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Impossible de charger les worktrees',
'sessions.sidebar.session.moveToWorktree.current': 'Worktree actuel',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session déplacée vers le worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Impossible de déplacer la session vers le worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Affiche les worktrees existants et loption den créer un nouveau pour cette session.',
'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 lactivité 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.',
+9
View File
@@ -537,8 +537,17 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '共有解除',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート',
'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktreeへ移動',
'sessions.sidebar.session.menu.newWorktree': '新しいworktree...',
'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました',
'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした',
'sessions.sidebar.session.moveToWorktree.main': 'メインworktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktreeを更新しています...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktreeを読み込めませんでした',
'sessions.sidebar.session.moveToWorktree.current': '現在のworktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'セッションをworktreeへ移動しました',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'セッションをworktreeへ移動できませんでした',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '既存のworktreeと、このセッション用に新しいworktreeを作成するオプションを表示します。',
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。',
+9
View File
@@ -537,8 +537,17 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '공유 해제',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktree로 이동',
'sessions.sidebar.session.menu.newWorktree': '새 worktree...',
'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다',
'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다',
'sessions.sidebar.session.moveToWorktree.main': '메인 worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktree 새로 고침 중...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktree를 불러오지 못했습니다',
'sessions.sidebar.session.moveToWorktree.current': '현재 worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '세션을 worktree로 이동했습니다',
'sessions.sidebar.session.moveToWorktree.existingFailed': '세션을 worktree로 이동하지 못했습니다',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '기존 worktree와 이 세션용 새 worktree를 만드는 옵션을 표시합니다.',
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.',
+9
View File
@@ -330,8 +330,17 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie',
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Przenieś do nowego worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Przenieś do worktree',
'sessions.sidebar.session.menu.newWorktree': 'Nowy 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.main': 'Główny worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Odświeżanie worktree...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Nie udało się wczytać worktree',
'sessions.sidebar.session.moveToWorktree.current': 'Bieżący worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sesję przeniesiono do worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Nie udało się przenieść sesji do worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Pokazuje istniejące worktree i opcję utworzenia nowego dla tej sesji.',
'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.',
@@ -537,8 +537,17 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Parar de compartilhar",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover para um novo worktree",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover para worktree",
"sessions.sidebar.session.menu.newWorktree": "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.main": "Worktree principal",
"sessions.sidebar.session.moveToWorktree.refreshing": "Atualizando worktrees...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "Não foi possível carregar os worktrees",
"sessions.sidebar.session.moveToWorktree.current": "Worktree atual",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sessão movida para o worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "Não foi possível mover a sessão para o worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Mostra os worktrees existentes e a opção de criar um novo para esta sessão.",
"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.",
+9
View File
@@ -537,8 +537,17 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ",
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Перенести в worktree",
"sessions.sidebar.session.menu.newWorktree": "Новий worktree...",
"sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree",
"sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree",
"sessions.sidebar.session.moveToWorktree.main": "Основний worktree",
"sessions.sidebar.session.moveToWorktree.refreshing": "Оновлення worktree...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "Не вдалося завантажити worktree",
"sessions.sidebar.session.moveToWorktree.current": "Поточний worktree",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Сесію перенесено в worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "Не вдалося перенести сесію в worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Показує наявні worktree і можливість створити новий для цієї сесії.",
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.",
@@ -537,8 +537,17 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树',
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作树',
'sessions.sidebar.session.menu.newWorktree': '新建工作树...',
'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树',
'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树',
'sessions.sidebar.session.moveToWorktree.main': '主工作树',
'sessions.sidebar.session.moveToWorktree.refreshing': '正在刷新工作树...',
'sessions.sidebar.session.moveToWorktree.loadFailed': '无法加载工作树',
'sessions.sidebar.session.moveToWorktree.current': '当前工作树',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '会话已移至工作树',
'sessions.sidebar.session.moveToWorktree.existingFailed': '无法将会话移至工作树',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '显示现有工作树,以及为此会话创建新工作树的选项。',
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。',
@@ -550,8 +550,17 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹',
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作樹',
'sessions.sidebar.session.menu.newWorktree': '新增工作樹...',
'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹',
'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹',
'sessions.sidebar.session.moveToWorktree.main': '主要工作樹',
'sessions.sidebar.session.moveToWorktree.refreshing': '正在重新整理工作樹...',
'sessions.sidebar.session.moveToWorktree.loadFailed': '無法載入工作樹',
'sessions.sidebar.session.moveToWorktree.current': '目前的工作樹',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '工作階段已移至工作樹',
'sessions.sidebar.session.moveToWorktree.existingFailed': '無法將工作階段移至工作樹',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '顯示現有工作樹,以及為此工作階段建立新工作樹的選項。',
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。',
@@ -0,0 +1,485 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Session, SessionStatus } from '@opencode-ai/sdk/v2';
import type { State } from '@/sync/types';
import type { WorktreeMetadata } from '@/types/worktree';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
const moveCalls: Array<{
sessionId: string;
sourceDirectory: string;
destinationDirectory: string;
moveChanges: boolean;
}> = [];
const refreshCalls: string[][] = [];
type RemoveProjectWorktreeOptions = { deleteLocalBranch: boolean };
type RemoveProjectWorktreeCall = {
project: ProjectRef;
worktree: WorktreeMetadata;
options: RemoveProjectWorktreeOptions;
};
type MoveSessionImplementation = (
session: Session,
sourceDirectory: string,
destinationDirectory: string,
moveChanges: boolean,
) => Promise<void>;
type RefreshImplementation = (directories: string[]) => Promise<void>;
type CreateQuickWorktreeOptions = { preferredName?: string; startRef?: string };
type CreateQuickWorktreeImplementation = (
project: ProjectRef,
options: CreateQuickWorktreeOptions,
) => Promise<WorktreeMetadata>;
type ResolveProjectRefImplementation = (directory: string) => ProjectRef | null;
type WaitForWorktreeGitReadyImplementation = (directory: string) => Promise<void>;
type DirectoryState = Pick<State, 'session_status'>;
type DeferredVoid = {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
};
const removeWorktreeCalls: RemoveProjectWorktreeCall[] = [];
const metadataWrites: Array<{ sessionId: string; metadata: WorktreeMetadata | null }> = [];
const latestMetadataInputs: WorktreeMetadata[] = [];
const toastSuccesses: string[] = [];
const toastErrors: Array<{ title: string; description?: string }> = [];
const directoryStates = new Map<string, DirectoryState>();
const storedMetadata = new Map<string, WorktreeMetadata | null>();
const originalConsoleWarn = console.warn;
let moveSessionImplementation: MoveSessionImplementation = async () => {};
let refreshImplementation: RefreshImplementation = async () => {};
let latestMetadataResult: WorktreeMetadata;
let createQuickWorktreeImplementation: CreateQuickWorktreeImplementation = async () => ({
path: '/created-worktree',
projectDirectory: '/repo',
branch: 'feature',
label: 'Created worktree',
worktreeStatus: 'ready',
worktreeSource: 'created-for-session',
});
let resolveProjectRefImplementation: ResolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' });
let waitForWorktreeGitReadyImplementation: WaitForWorktreeGitReadyImplementation = async () => {};
mock.module('@/components/ui', () => ({
toast: {
success: (message: string) => {
toastSuccesses.push(message);
},
error: (title: string, options?: { description?: string }) => {
toastErrors.push({ title, description: options?.description });
},
},
}));
mock.module('@/lib/gitApi', () => ({
getGitStatus: mock(() => Promise.resolve({ current: 'feature' })),
}));
mock.module('@/lib/worktreeSessionCreator', () => ({
createQuickWorktree: mock((project: ProjectRef, options: CreateQuickWorktreeOptions) => createQuickWorktreeImplementation(project, options)),
resolveProjectRef: mock((directory: string) => resolveProjectRefImplementation(directory)),
}));
mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
waitForWorktreeGitReady: mock((directory: string) => waitForWorktreeGitReadyImplementation(directory)),
}));
mock.module('@/lib/worktrees/worktreeManager', () => ({
getLatestWorktreeMetadata: (metadata: WorktreeMetadata) => {
latestMetadataInputs.push(metadata);
return latestMetadataResult;
},
removeProjectWorktree: (project: ProjectRef, worktree: WorktreeMetadata, options: RemoveProjectWorktreeOptions) => {
removeWorktreeCalls.push({ project, worktree, options });
return Promise.resolve();
},
}));
mock.module('@/stores/useGlobalSessionsStore', () => ({
refreshGlobalSessionsForDirectories: (directories: string[]) => {
refreshCalls.push(directories);
return refreshImplementation(directories);
},
}));
mock.module('@/sync/session-actions', () => ({
moveSessionToDirectory: (session: Session, sourceDirectory: string, destinationDirectory: string, moveChanges = true) => {
moveCalls.push({ sessionId: session.id, sourceDirectory, destinationDirectory, moveChanges });
return moveSessionImplementation(session, sourceDirectory, destinationDirectory, moveChanges);
},
}));
mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: {
getState: () => ({
availableWorktrees: [],
availableWorktreesByProject: new Map<string, WorktreeMetadata[]>(),
getWorktreeMetadata: (sessionId: string) => storedMetadata.get(sessionId) ?? null,
setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => {
storedMetadata.set(sessionId, metadata);
metadataWrites.push({ sessionId, metadata });
},
}),
},
}));
mock.module('@/sync/sync-refs', () => ({
getDirectoryState: (directory: string) => directoryStates.get(directory),
}));
const {
moveSessionTreeToExistingWorktree,
startSessionTreeWorktreeMove,
} = await import('./sessionWorktreeMove');
const makeSession = (id: string, directory = '/source'): Session => ({
id,
slug: id,
projectID: 'project-1',
directory,
title: id,
version: '1',
time: {
created: 0,
updated: 0,
},
});
const makeWorktreeMetadata = (overrides: Partial<WorktreeMetadata> = {}): WorktreeMetadata => ({
path: '/destination',
projectDirectory: '/repo',
branch: 'feature',
label: 'Destination',
worktreeStatus: 'ready',
worktreeSource: 'existing',
...overrides,
});
const makeSessionStatus = (type: SessionStatus['type']): SessionStatus => {
switch (type) {
case 'busy':
return { type: 'busy' };
case 'idle':
return { type: 'idle' };
case 'retry':
return { type: 'retry', attempt: 1, message: 'retry', next: 0 };
}
};
const setStatuses = (directory: string, statuses: Record<string, State['session_status'][string]['type']>): void => {
directoryStates.set(directory, {
session_status: Object.fromEntries(
Object.entries(statuses).map(([sessionId, type]) => [sessionId, makeSessionStatus(type)]),
),
});
};
const waitFor = async (predicate: () => boolean): Promise<void> => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) return;
await Promise.resolve();
}
throw new Error('Timed out waiting for condition');
};
const deferred = (): DeferredVoid => {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe('moveSessionTreeToExistingWorktree', () => {
beforeEach(() => {
moveCalls.length = 0;
refreshCalls.length = 0;
removeWorktreeCalls.length = 0;
metadataWrites.length = 0;
latestMetadataInputs.length = 0;
toastSuccesses.length = 0;
toastErrors.length = 0;
directoryStates.clear();
storedMetadata.clear();
latestMetadataResult = makeWorktreeMetadata({ label: 'Latest destination' });
moveSessionImplementation = async () => {};
refreshImplementation = async () => {};
createQuickWorktreeImplementation = async () => makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session' });
resolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' });
waitForWorktreeGitReadyImplementation = async () => {};
console.warn = () => {};
});
afterEach(() => {
console.warn = originalConsoleWarn;
});
test('moves the root before descendants, only transfers changes once, and refreshes both directories', async () => {
const root = makeSession('root');
const child = makeSession('child');
const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' });
const previousChildMetadata = makeWorktreeMetadata({ path: '/old-child', label: 'Old child' });
const destination = makeWorktreeMetadata();
setStatuses('/source', { root: 'idle', child: 'idle' });
storedMetadata.set(root.id, previousRootMetadata);
storedMetadata.set(child.id, previousChildMetadata);
const result = await moveSessionTreeToExistingWorktree({
root,
descendants: [child],
sourceDirectory: '/source',
destination,
});
expect(result).toBe('/destination');
expect(moveCalls).toEqual([
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true },
{ sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
]);
expect(metadataWrites).toEqual([
{ sessionId: 'root', metadata: latestMetadataResult },
{ sessionId: 'child', metadata: latestMetadataResult },
]);
expect(latestMetadataInputs).toEqual([destination, destination]);
expect(refreshCalls).toEqual([['/source', '/destination']]);
expect(removeWorktreeCalls).toEqual([]);
});
test('rejects a destination that normalizes to the source directory', async () => {
setStatuses('/source', { root: 'idle' });
await expect(moveSessionTreeToExistingWorktree({
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source/',
destination: makeWorktreeMetadata({ path: '/source' }),
})).rejects.toThrow('Source and destination are the same');
expect(moveCalls).toEqual([]);
expect(refreshCalls).toEqual([]);
});
test('rejects a destination worktree that is not ready', async () => {
setStatuses('/source', { root: 'idle' });
await expect(moveSessionTreeToExistingWorktree({
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
destination: makeWorktreeMetadata({ worktreeStatus: 'pending' }),
})).rejects.toThrow('Destination worktree is not ready');
expect(moveCalls).toEqual([]);
});
test('rejects when the root session is busy before setup', async () => {
setStatuses('/source', { root: 'busy' });
await expect(moveSessionTreeToExistingWorktree({
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
destination: makeWorktreeMetadata(),
})).rejects.toThrow('Session is not idle');
expect(moveCalls).toEqual([]);
});
test('rejects when any descendant is busy before setup', async () => {
const root = makeSession('root');
const child = makeSession('child');
setStatuses('/source', { root: 'idle', child: 'retry' });
await expect(moveSessionTreeToExistingWorktree({
root,
descendants: [child],
sourceDirectory: '/source',
destination: makeWorktreeMetadata(),
})).rejects.toThrow('Session is not idle');
expect(moveCalls).toEqual([]);
});
test('rejects a duplicate move request while the root move is pending', async () => {
const root = makeSession('root');
const rootMove = deferred();
setStatuses('/source', { root: 'idle' });
moveSessionImplementation = async (session, sourceDirectory) => {
if (session.id === 'root' && sourceDirectory === '/source') {
return rootMove.promise;
}
};
const firstMove = moveSessionTreeToExistingWorktree({
root,
descendants: [],
sourceDirectory: '/source',
destination: makeWorktreeMetadata(),
});
await waitFor(() => moveCalls.length === 1);
await expect(moveSessionTreeToExistingWorktree({
root,
descendants: [],
sourceDirectory: '/source',
destination: makeWorktreeMetadata(),
})).rejects.toThrow('Session move already in progress');
rootMove.resolve();
await firstMove;
expect(moveCalls).toHaveLength(1);
});
test('rolls back completed moves in reverse order, restores previous metadata, and never removes an existing destination', async () => {
const root = makeSession('root');
const childA = makeSession('child-a');
const childB = makeSession('child-b');
const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' });
const previousChildAMetadata = makeWorktreeMetadata({ path: '/old-child-a', label: 'Old child A' });
const previousChildBMetadata = makeWorktreeMetadata({ path: '/old-child-b', label: 'Old child B' });
setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' });
storedMetadata.set(root.id, previousRootMetadata);
storedMetadata.set(childA.id, previousChildAMetadata);
storedMetadata.set(childB.id, previousChildBMetadata);
moveSessionImplementation = async (session, sourceDirectory) => {
if (session.id === 'child-b' && sourceDirectory === '/source') {
throw new Error('child-b failed');
}
};
await expect(moveSessionTreeToExistingWorktree({
root,
descendants: [childA, childB],
sourceDirectory: '/source',
destination: makeWorktreeMetadata(),
})).rejects.toThrow('child-b failed');
expect(moveCalls).toEqual([
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true },
{ sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
{ sessionId: 'child-b', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
{ sessionId: 'child-a', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false },
{ sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true },
]);
expect(metadataWrites).toEqual([
{ sessionId: 'root', metadata: latestMetadataResult },
{ sessionId: 'child-a', metadata: latestMetadataResult },
{ sessionId: 'child-a', metadata: previousChildAMetadata },
{ sessionId: 'root', metadata: previousRootMetadata },
]);
expect(storedMetadata.get(root.id)).toBe(previousRootMetadata);
expect(storedMetadata.get(childA.id)).toBe(previousChildAMetadata);
expect(storedMetadata.get(childB.id)).toBe(previousChildBMetadata);
expect(removeWorktreeCalls).toEqual([]);
expect(refreshCalls).toEqual([]);
});
test('reports an incomplete rollback explicitly and still does not remove the existing destination', async () => {
const root = makeSession('root');
const child = makeSession('child');
setStatuses('/source', { root: 'idle', child: 'idle' });
moveSessionImplementation = async (session, sourceDirectory) => {
if (session.id === 'child' && sourceDirectory === '/source') {
throw new Error('child failed');
}
if (session.id === 'root' && sourceDirectory === '/destination') {
throw new Error('rollback failed');
}
};
await expect(moveSessionTreeToExistingWorktree({
root,
descendants: [child],
sourceDirectory: '/source',
destination: makeWorktreeMetadata(),
})).rejects.toThrow('could not be fully rolled back');
expect(removeWorktreeCalls).toEqual([]);
});
test('keeps the move successful when the post-move refresh fails', async () => {
const root = makeSession('root');
setStatuses('/source', { root: 'idle' });
refreshImplementation = async () => {
throw new Error('refresh failed');
};
const result = await moveSessionTreeToExistingWorktree({
root,
descendants: [],
sourceDirectory: '/source',
destination: makeWorktreeMetadata(),
});
expect(result).toBe('/destination');
expect(refreshCalls).toEqual([['/source', '/destination']]);
});
test('removes a newly created worktree when git-ready setup fails', async () => {
setStatuses('/source', { root: 'idle' });
waitForWorktreeGitReadyImplementation = async () => {
throw new Error('git-ready failed');
};
startSessionTreeWorktreeMove({
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
successMessage: 'success',
failureMessage: 'failed',
});
await waitFor(() => toastErrors.length === 1);
expect(toastErrors).toEqual([{ title: 'failed', description: 'git-ready failed' }]);
expect(removeWorktreeCalls).toEqual([{
project: { id: 'project-1', path: '/repo' },
worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }),
options: { deleteLocalBranch: true },
}]);
expect(moveCalls).toEqual([]);
});
test('removes a newly created worktree when a session becomes busy before the first move', async () => {
setStatuses('/source', { root: 'idle' });
waitForWorktreeGitReadyImplementation = async () => {
setStatuses('/source', { root: 'busy' });
};
startSessionTreeWorktreeMove({
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
successMessage: 'success',
failureMessage: 'failed',
});
await waitFor(() => toastErrors.length === 1);
expect(removeWorktreeCalls).toEqual([{
project: { id: 'project-1', path: '/repo' },
worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }),
options: { deleteLocalBranch: true },
}]);
expect(moveCalls).toEqual([]);
});
test('surfaces a pre-destination preparation failure without attempting removal', async () => {
setStatuses('/source', { root: 'idle' });
resolveProjectRefImplementation = () => null;
startSessionTreeWorktreeMove({
root: makeSession('root'),
descendants: [],
sourceDirectory: '/source',
successMessage: 'success',
failureMessage: 'failed',
});
await waitFor(() => toastErrors.length === 1);
expect(toastErrors).toEqual([{ title: 'failed', description: 'Unable to find the project for this session' }]);
expect(removeWorktreeCalls).toEqual([]);
expect(moveCalls).toEqual([]);
});
});
@@ -88,31 +88,34 @@ const rollbackMovedSessions = async (
const removeFailedWorktree = async (
project: ProjectRef,
worktree: WorktreeMetadata,
moveError: unknown,
moveError: Error,
): 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 new Error(`Session move failed and the new worktree could not be removed: ${moveError.message}`);
}
throw moveError;
};
const moveSessionTreeToQuickWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
}): Promise<string> => {
const moveSessionTreeTransaction = async (
input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
},
prepareDestination: () => Promise<{
directory: string;
metadata: WorktreeMetadata;
onMoveFailure?: (error: Error) => Promise<never>;
}>,
): 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) => [
@@ -122,49 +125,112 @@ const moveSessionTreeToQuickWorktree = async (input: {
);
assertSessionsIdle(sessions, input.sourceDirectory);
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
let destination: Awaited<ReturnType<typeof prepareDestination>> | null = null;
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.
destination = await prepareDestination();
// Setup can take long enough for one of the sessions to start running, so
// verify the whole tree again immediately before the first move.
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);
await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, index === 0);
moved.push(session);
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree));
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata));
}
} catch (error) {
const moveError = error instanceof Error ? error : new Error(String(error));
const rollbackFailures = await rollbackMovedSessions(
moved,
input.root.id,
input.sourceDirectory,
worktree.path,
destination?.directory ?? input.sourceDirectory,
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)}`);
throw new Error(`Session move partially failed and could not be fully rolled back: ${moveError.message}`);
}
return removeFailedWorktree(project, worktree, error);
if (destination?.onMoveFailure) {
return destination.onMoveFailure(moveError);
}
throw moveError;
}
try {
await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]);
await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]);
} 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;
return destination.directory;
} finally {
setSessionMovePending(input.root.id, false);
}
};
export const moveSessionTreeToExistingWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
destination: WorktreeMetadata;
}): Promise<string> => {
const normalizedSourceDirectory = normalizePath(input.sourceDirectory) ?? input.sourceDirectory;
const normalizedDestinationDirectory = normalizePath(input.destination.path) ?? input.destination.path;
if (normalizedSourceDirectory === normalizedDestinationDirectory) {
throw new Error('Source and destination are the same');
}
if (input.destination.worktreeStatus !== 'ready') {
throw new Error('Destination worktree is not ready');
}
return moveSessionTreeTransaction(input, async () => ({
directory: input.destination.path,
metadata: input.destination,
}));
};
const moveSessionTreeToQuickWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
}): Promise<string> => {
return moveSessionTreeTransaction(input, async () => {
const project = resolveProjectRef(input.sourceDirectory);
if (!project) throw new Error('Unable to find the project for this session');
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
try {
await waitForWorktreeGitReady(worktree.path);
} catch (error) {
const setupError = error instanceof Error ? error : new Error(String(error));
return removeFailedWorktree(project, worktree, setupError);
}
return {
directory: worktree.path,
metadata: worktree,
onMoveFailure: (error) => removeFailedWorktree(project, worktree, error),
};
});
};
export const startSessionTreeExistingWorktreeMove = (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
destination: WorktreeMetadata;
successMessage: string;
failureMessage: string;
}): void => {
void moveSessionTreeToExistingWorktree(input)
.then(() => toast.success(input.successMessage))
.catch((error) => toast.error(input.failureMessage, {
description: error instanceof Error ? error.message : String(error),
}));
};
export const startSessionTreeWorktreeMove = (input: {
root: Session;
descendants: Session[];
@@ -11,6 +11,7 @@ type WorktreeListEntry = {
const listCalls: string[] = [];
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
const listRejecters: Array<(reason: Error) => void> = [];
const createPayloads: unknown[] = [];
const validatePayloads: unknown[] = [];
const createdWorktree = {
@@ -78,8 +79,9 @@ mock.module('@/lib/gitApi', () => ({
worktree: {
list: (directory: string) => {
listCalls.push(directory);
return new Promise<WorktreeListEntry[]>((resolve) => {
return new Promise<WorktreeListEntry[]>((resolve, reject) => {
listResolvers.push(resolve);
listRejecters.push((reason: Error) => reject(reason));
});
},
create: mock((_directory: string, payload: unknown) => {
@@ -118,6 +120,7 @@ describe('worktreeManager list invalidation', () => {
beforeEach(() => {
listCalls.length = 0;
listResolvers.length = 0;
listRejecters.length = 0;
createPayloads.length = 0;
validatePayloads.length = 0;
bootstrapWatcherCalls.length = 0;
@@ -152,6 +155,85 @@ describe('worktreeManager list invalidation', () => {
expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']);
});
test('forced refresh bypasses a fresh cached result', async () => {
const project = { id: 'project-force-cache', path: '/repo-force-cache' };
const initialListing = listProjectWorktrees(project);
await waitForListCallCount(1);
listResolvers[0]([]);
const initialResult = await initialListing;
expect(initialResult).toEqual([]);
const cachedResult = await listProjectWorktrees(project);
expect(cachedResult).toEqual([]);
expect(listCalls).toEqual(['/repo-force-cache']);
const forcedListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(2);
listResolvers[1]([createdWorktree]);
const forcedResult = await forcedListing;
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']);
const refreshedCachedResult = await listProjectWorktrees(project);
expect(refreshedCachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']);
});
test('forced refresh starts a new request instead of joining an older in-flight list', async () => {
const project = { id: 'project-force-inflight', path: '/repo-force-inflight' };
const initialListing = listProjectWorktrees(project);
await waitForListCallCount(1);
const forcedListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(2);
listResolvers[1]([createdWorktree]);
const forcedResult = await forcedListing;
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
listResolvers[0]([]);
await waitForListCallCount(3);
listResolvers[2]([createdWorktree]);
const initialResult = await initialListing;
expect(initialResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
expect(listCalls).toEqual([
'/repo-force-inflight',
'/repo-force-inflight',
'/repo-force-inflight',
]);
});
test('older completions do not replace a forced refresh result with stale topology', async () => {
const project = { id: 'project-force-stale', path: '/repo-force-stale' };
void listProjectWorktrees(project);
await waitForListCallCount(1);
const forcedListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(2);
listResolvers[1]([createdWorktree]);
const forcedResult = await forcedListing;
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
listResolvers[0]([{ path: '/repo-stale', branch: 'stale', name: 'stale' }]);
await waitForListCallCount(3);
const cachedResult = await listProjectWorktrees(project);
expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
});
test('rejects when git worktree listing fails', async () => {
const project = { id: 'project-force-failure', path: '/repo-force-failure' };
const listing = listProjectWorktrees(project);
await waitForListCallCount(1);
listRejecters[0](new Error('git failed'));
await expect(listing).rejects.toThrow('git failed');
});
test('marks fast-created worktrees pending until bootstrap settles', async () => {
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'feature',
@@ -389,6 +471,7 @@ describe('worktreeManager fork remote payload wiring', () => {
beforeEach(() => {
listCalls.length = 0;
listResolvers.length = 0;
listRejecters.length = 0;
createPayloads.length = 0;
validatePayloads.length = 0;
bootstrapWatcherCalls.length = 0;
@@ -374,7 +374,7 @@ export const partitionWorktreesByRegisteredProject = (
// Cache worktree listings to avoid repeated git worktree list + rev-parse calls
const _worktreeListCache = new Map<string, { value: WorktreeMetadata[]; at: number }>();
const _worktreeListInflight = new Map<string, Promise<WorktreeMetadata[]>>();
const _worktreeListInflight = new Map<string, { generation: number; promise: Promise<WorktreeMetadata[]> }>();
const _worktreeListGeneration = new Map<string, number>();
const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds
@@ -391,7 +391,7 @@ const readProjectWorktrees = async (projectDirectory: string): Promise<WorktreeM
const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory);
const normalizedProjectDirectory = normalizePath(projectDirectory);
const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
const worktrees = await git.worktree.list(projectDirectory);
const results: WorktreeMetadata[] = worktrees
.filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0)
.map((entry) => {
@@ -424,38 +424,48 @@ const readProjectWorktrees = async (projectDirectory: string): Promise<WorktreeM
});
};
const readStableProjectWorktrees = async (projectDirectory: string): Promise<WorktreeMetadata[]> => {
const readStableProjectWorktrees = async (
projectDirectory: string,
minimumGeneration = getWorktreeListGeneration(projectDirectory),
): Promise<WorktreeMetadata[]> => {
while (true) {
const generation = getWorktreeListGeneration(projectDirectory);
const worktrees = await readProjectWorktrees(projectDirectory);
if (generation === getWorktreeListGeneration(projectDirectory)) {
if (generation >= minimumGeneration && generation === getWorktreeListGeneration(projectDirectory)) {
_worktreeListCache.set(projectDirectory, { value: worktrees, at: Date.now() });
return worktrees;
}
}
};
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
export async function listProjectWorktrees(project: ProjectRef, options?: { force?: boolean }): Promise<WorktreeMetadata[]> {
const projectDirectory = normalizePath(project.path);
const force = options?.force === true;
if (force) {
invalidateWorktreeList(projectDirectory);
}
const generation = getWorktreeListGeneration(projectDirectory);
// Return cached if fresh
const cached = _worktreeListCache.get(projectDirectory);
if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) {
if (!force && cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) {
return cached.value;
}
// Dedup in-flight requests
const inflight = _worktreeListInflight.get(projectDirectory);
if (inflight) return inflight;
if (inflight && inflight.generation === generation) return inflight.promise;
const promise = readStableProjectWorktrees(projectDirectory).finally(() => {
if (_worktreeListInflight.get(projectDirectory) === promise) {
const promise = readStableProjectWorktrees(projectDirectory, generation).finally(() => {
if (_worktreeListInflight.get(projectDirectory)?.promise === promise) {
_worktreeListInflight.delete(projectDirectory);
}
});
_worktreeListInflight.set(projectDirectory, promise);
_worktreeListInflight.set(projectDirectory, { generation, promise });
return promise;
}