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:
@@ -0,0 +1,180 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getGitStatus } from '@/lib/gitApi';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
|
||||
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { refreshGlobalSessionsForDirectories } from '@/stores/useGlobalSessionsStore';
|
||||
import { moveSessionToDirectory } from '@/sync/session-actions';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getDirectoryState } from '@/sync/sync-refs';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useSessionMoveState = create<{ pendingSessionIds: Set<string> }>(() => ({
|
||||
pendingSessionIds: new Set(),
|
||||
}));
|
||||
|
||||
export const useIsSessionWorktreeMovePending = (sessionId: string): boolean =>
|
||||
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId));
|
||||
|
||||
const setSessionMovePending = (sessionId: string, pending: boolean): void => {
|
||||
useSessionMoveState.setState((state) => {
|
||||
if (state.pendingSessionIds.has(sessionId) === pending) return state;
|
||||
const pendingSessionIds = new Set(state.pendingSessionIds);
|
||||
if (pending) pendingSessionIds.add(sessionId);
|
||||
else pendingSessionIds.delete(sessionId);
|
||||
return { pendingSessionIds };
|
||||
});
|
||||
};
|
||||
|
||||
const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise<string> => {
|
||||
try {
|
||||
const status = await getGitStatus(directory, { mode: 'light' });
|
||||
const currentBranch = status.current?.trim();
|
||||
if (currentBranch) return currentBranch;
|
||||
} catch {
|
||||
// Fall back to discovered worktree metadata below.
|
||||
}
|
||||
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
const normalizedProjectDirectory = normalizePath(projectDirectory) ?? projectDirectory;
|
||||
const worktrees = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
const metadata = (worktrees.get(normalizedProjectDirectory) ?? worktrees.get(projectDirectory) ?? [])
|
||||
.find((worktree) => normalizePath(worktree.path) === normalizedDirectory);
|
||||
const mappedBranch = metadata?.branch?.trim();
|
||||
if (mappedBranch) return mappedBranch;
|
||||
|
||||
throw new Error('Unable to determine the current branch');
|
||||
};
|
||||
|
||||
const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void => {
|
||||
const directoryState = getDirectoryState(sourceDirectory);
|
||||
if (!directoryState) throw new Error('Session status is unavailable');
|
||||
|
||||
const statuses = directoryState.session_status;
|
||||
const hasActiveSession = sessions.some((session) => {
|
||||
const status = statuses[session.id]?.type;
|
||||
return status === 'busy' || status === 'retry';
|
||||
});
|
||||
if (hasActiveSession) throw new Error('Session is not idle');
|
||||
};
|
||||
|
||||
const rollbackMovedSessions = async (
|
||||
sessions: Session[],
|
||||
rootSessionId: string,
|
||||
sourceDirectory: string,
|
||||
worktreeDirectory: string,
|
||||
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
|
||||
): Promise<unknown[]> => {
|
||||
const failures: unknown[] = [];
|
||||
for (const session of [...sessions].reverse()) {
|
||||
try {
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
worktreeDirectory,
|
||||
sourceDirectory,
|
||||
session.id === rootSessionId,
|
||||
);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
};
|
||||
|
||||
const removeFailedWorktree = async (
|
||||
project: ProjectRef,
|
||||
worktree: WorktreeMetadata,
|
||||
moveError: unknown,
|
||||
): Promise<never> => {
|
||||
try {
|
||||
await removeProjectWorktree(project, worktree, { deleteLocalBranch: true });
|
||||
} catch {
|
||||
const message = moveError instanceof Error ? moveError.message : String(moveError);
|
||||
throw new Error(`Session move failed and the new worktree could not be removed: ${message}`);
|
||||
}
|
||||
throw moveError;
|
||||
};
|
||||
|
||||
const moveSessionTreeToQuickWorktree = async (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
}): Promise<string> => {
|
||||
if (useSessionMoveState.getState().pendingSessionIds.has(input.root.id)) {
|
||||
throw new Error('Session move already in progress');
|
||||
}
|
||||
setSessionMovePending(input.root.id, true);
|
||||
|
||||
try {
|
||||
const project = resolveProjectRef(input.sourceDirectory);
|
||||
if (!project) throw new Error('Unable to find the project for this session');
|
||||
|
||||
const sessions = [input.root, ...input.descendants];
|
||||
const previousMetadata = new Map(
|
||||
sessions.map((session) => [
|
||||
session.id,
|
||||
useSessionUIStore.getState().getWorktreeMetadata(session.id),
|
||||
]),
|
||||
);
|
||||
assertSessionsIdle(sessions, input.sourceDirectory);
|
||||
|
||||
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
|
||||
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
|
||||
|
||||
const moved: Session[] = [];
|
||||
try {
|
||||
await waitForWorktreeGitReady(worktree.path);
|
||||
// Branch/status discovery and worktree creation can take long enough for a
|
||||
// session to start running, so verify the whole tree again before moving.
|
||||
assertSessionsIdle(sessions, input.sourceDirectory);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
// Transfer the checkout changes once with the root. Descendants only
|
||||
// need their execution location updated.
|
||||
await moveSessionToDirectory(session, input.sourceDirectory, worktree.path, index === 0);
|
||||
moved.push(session);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree));
|
||||
}
|
||||
} catch (error) {
|
||||
const rollbackFailures = await rollbackMovedSessions(
|
||||
moved,
|
||||
input.root.id,
|
||||
input.sourceDirectory,
|
||||
worktree.path,
|
||||
previousMetadata,
|
||||
);
|
||||
if (rollbackFailures.length > 0) {
|
||||
throw new Error(`Session move partially failed and could not be fully rolled back: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
return removeFailedWorktree(project, worktree, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]);
|
||||
} catch (error) {
|
||||
// Direct action updates already reconciled both stores. Keep the move
|
||||
// successful if this best-effort authoritative refresh is unavailable.
|
||||
console.warn('[session-worktree-move] Failed to refresh moved sessions', error);
|
||||
}
|
||||
return worktree.path;
|
||||
} finally {
|
||||
setSessionMovePending(input.root.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
export const startSessionTreeWorktreeMove = (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
successMessage: string;
|
||||
failureMessage: string;
|
||||
}): void => {
|
||||
void moveSessionTreeToQuickWorktree(input)
|
||||
.then(() => toast.success(input.successMessage))
|
||||
.catch((error) => toast.error(input.failureMessage, {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
|
||||
|
||||
const bootstrapStatusCalls: string[] = [];
|
||||
let bootstrapStatusResult: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number } = {
|
||||
let bootstrapStatusResult: GitWorktreeBootstrapStatus = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
updatedAt: 1,
|
||||
};
|
||||
let getBootstrapStatus = (): Promise<GitWorktreeBootstrapStatus> => Promise.resolve(bootstrapStatusResult);
|
||||
const toastErrors: Array<{ title: string; description?: string }> = [];
|
||||
|
||||
mock.module('@/components/ui', () => ({
|
||||
@@ -29,7 +31,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
|
||||
worktree: {
|
||||
bootstrapStatus: (directory: string) => {
|
||||
bootstrapStatusCalls.push(directory);
|
||||
return Promise.resolve(bootstrapStatusResult);
|
||||
return getBootstrapStatus();
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -39,7 +41,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
|
||||
mock.module('@/lib/gitApiHttp', () => ({
|
||||
getGitWorktreeBootstrapStatus: (directory: string) => {
|
||||
bootstrapStatusCalls.push(directory);
|
||||
return Promise.resolve(bootstrapStatusResult);
|
||||
return getBootstrapStatus();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -47,8 +49,10 @@ const {
|
||||
clearWorktreeBootstrapState,
|
||||
getWorktreeBootstrapState,
|
||||
markWorktreeBootstrapPending,
|
||||
setWorktreeBootstrapState,
|
||||
startWorktreeBootstrapWatcher,
|
||||
waitForWorktreeBootstrap,
|
||||
waitForWorktreeGitReady,
|
||||
} = await import('./worktreeBootstrap');
|
||||
|
||||
const waitFor = async (predicate: () => boolean): Promise<void> => {
|
||||
@@ -64,6 +68,7 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
|
||||
bootstrapStatusCalls.length = 0;
|
||||
toastErrors.length = 0;
|
||||
bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 };
|
||||
getBootstrapStatus = () => Promise.resolve(bootstrapStatusResult);
|
||||
clearWorktreeBootstrapState('/repo');
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
});
|
||||
@@ -82,6 +87,108 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
});
|
||||
|
||||
test('git-ready wait does not wait for setup-ready', async () => {
|
||||
setWorktreeBootstrapState('/repo-wt', {
|
||||
status: 'pending',
|
||||
phase: 'git-ready',
|
||||
error: null,
|
||||
updatedAt: 1,
|
||||
});
|
||||
|
||||
await waitForWorktreeGitReady('/repo-wt');
|
||||
|
||||
expect(bootstrapStatusCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('git-ready wait remains compatible with ready responses that omit phase', async () => {
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
await waitForWorktreeGitReady('/repo-wt');
|
||||
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
expect(getWorktreeBootstrapState('/repo-wt')).toEqual(bootstrapStatusResult);
|
||||
});
|
||||
|
||||
test('dedupes concurrent waiters for the same phase', async () => {
|
||||
let resolveStatus!: (status: GitWorktreeBootstrapStatus) => void;
|
||||
getBootstrapStatus = () => new Promise((resolve) => {
|
||||
resolveStatus = resolve;
|
||||
});
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
const first = waitForWorktreeGitReady('/repo-wt');
|
||||
const second = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => bootstrapStatusCalls.length === 1);
|
||||
resolveStatus({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
|
||||
|
||||
await Promise.all([first, second]);
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
});
|
||||
|
||||
test('does not let a cleared waiter restore stale state or remove a replacement waiter', async () => {
|
||||
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
|
||||
getBootstrapStatus = () => new Promise((resolve) => {
|
||||
statusResolvers.push(resolve);
|
||||
});
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
const staleWaiter = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => statusResolvers.length === 1);
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
const replacementWaiter = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => statusResolvers.length === 2);
|
||||
|
||||
statusResolvers[0]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
|
||||
await expect(staleWaiter).rejects.toThrow('cancelled');
|
||||
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('directory-created');
|
||||
|
||||
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
|
||||
await replacementWaiter;
|
||||
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt', '/repo-wt']);
|
||||
});
|
||||
|
||||
test('does not regress a newer phase when concurrent polls resolve out of order', async () => {
|
||||
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
|
||||
getBootstrapStatus = () => new Promise((resolve) => {
|
||||
statusResolvers.push(resolve);
|
||||
});
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
|
||||
await waitFor(() => statusResolvers.length === 1);
|
||||
const gitReadyWaiter = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => statusResolvers.length === 2);
|
||||
|
||||
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
|
||||
await gitReadyWaiter;
|
||||
statusResolvers[0]({ status: 'pending', phase: 'directory-created', error: null, updatedAt: 2 });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
});
|
||||
|
||||
test('full bootstrap wait continues through git-ready until setup-ready', async () => {
|
||||
setWorktreeBootstrapState('/repo-wt', {
|
||||
status: 'pending',
|
||||
phase: 'git-ready',
|
||||
error: null,
|
||||
updatedAt: 1,
|
||||
});
|
||||
bootstrapStatusResult = {
|
||||
status: 'ready',
|
||||
phase: 'setup-ready',
|
||||
error: null,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
await waitForWorktreeBootstrap('/repo-wt');
|
||||
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
});
|
||||
|
||||
test('background watcher polls pending worktrees without blocking', async () => {
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
|
||||
|
||||
@@ -14,10 +14,66 @@ const POLL_INTERVAL_MS = 250;
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
|
||||
const state = new Map<string, WorktreeBootstrapState>();
|
||||
type WorktreeBootstrapTarget = 'git-ready' | 'setup-ready';
|
||||
|
||||
const waiters = new Map<string, Promise<void>>();
|
||||
const watchers = new Map<string, { cancelled: boolean; promise: Promise<void> }>();
|
||||
const lifecycleVersions = new Map<string, number>();
|
||||
let nextLifecycleVersion = 0;
|
||||
const watchers = new Map<string, { cancelled: boolean; lifecycleVersion: number }>();
|
||||
|
||||
const getKey = (directory: string): string => normalizePath(directory);
|
||||
const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`;
|
||||
|
||||
const startLifecycle = (key: string): void => {
|
||||
const watcher = watchers.get(key);
|
||||
if (watcher) {
|
||||
watcher.cancelled = true;
|
||||
watchers.delete(key);
|
||||
}
|
||||
|
||||
waiters.delete(getWaiterKey(key, 'git-ready'));
|
||||
waiters.delete(getWaiterKey(key, 'setup-ready'));
|
||||
|
||||
const version = ++nextLifecycleVersion;
|
||||
lifecycleVersions.set(key, version);
|
||||
};
|
||||
|
||||
const isCurrentLifecycle = (key: string, version: number): boolean => lifecycleVersions.get(key) === version;
|
||||
|
||||
const phaseRank = (phase: GitWorktreeBootstrapStatus['phase']): number => {
|
||||
switch (phase) {
|
||||
case 'setup-ready':
|
||||
return 2;
|
||||
case 'git-ready':
|
||||
return 1;
|
||||
case 'directory-created':
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const storePolledState = (
|
||||
key: string,
|
||||
next: WorktreeBootstrapState,
|
||||
lifecycleVersion: number,
|
||||
): WorktreeBootstrapState | null => {
|
||||
if (!isCurrentLifecycle(key, lifecycleVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = state.get(key);
|
||||
const wouldRegressReadyState = current?.status === 'ready' && next.status === 'pending';
|
||||
const wouldRegressPendingPhase = current?.status === 'pending'
|
||||
&& next.status === 'pending'
|
||||
&& phaseRank(next.phase) < phaseRank(current.phase);
|
||||
|
||||
if (wouldRegressReadyState || wouldRegressPendingPhase) {
|
||||
return current;
|
||||
}
|
||||
|
||||
state.set(key, next);
|
||||
return next;
|
||||
};
|
||||
|
||||
const getGitWorktreeBootstrapStatus = async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
|
||||
const runtimeGit = getRegisteredRuntimeAPIs()?.git;
|
||||
@@ -35,8 +91,10 @@ export const markWorktreeBootstrapPending = (directory: string): void => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
startLifecycle(key);
|
||||
state.set(key, {
|
||||
status: 'pending',
|
||||
phase: 'directory-created',
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
@@ -47,13 +105,9 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const watcher = watchers.get(key);
|
||||
if (watcher) {
|
||||
watcher.cancelled = true;
|
||||
watchers.delete(key);
|
||||
}
|
||||
startLifecycle(key);
|
||||
state.delete(key);
|
||||
waiters.delete(key);
|
||||
lifecycleVersions.delete(key);
|
||||
};
|
||||
|
||||
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
|
||||
@@ -61,10 +115,8 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
startLifecycle(key);
|
||||
state.set(key, next);
|
||||
if (next.status !== 'pending') {
|
||||
waiters.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
|
||||
@@ -97,19 +149,34 @@ const markBootstrapFailed = (
|
||||
return failed;
|
||||
};
|
||||
|
||||
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => {
|
||||
const hasReachedTarget = (status: GitWorktreeBootstrapStatus, target: WorktreeBootstrapTarget): boolean => {
|
||||
if (status.status === 'ready') return true;
|
||||
if (target === 'git-ready' && (status.phase === 'git-ready' || status.phase === 'setup-ready')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const pollWorktreeBootstrapUntilSettled = async (
|
||||
directory: string,
|
||||
key: string,
|
||||
lifecycleVersion: number,
|
||||
timeoutMs: number,
|
||||
target: WorktreeBootstrapTarget,
|
||||
): Promise<void> => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await getGitWorktreeBootstrapStatus(directory);
|
||||
setWorktreeBootstrapState(directory, result);
|
||||
const current = storePolledState(key, result, lifecycleVersion);
|
||||
if (!current) {
|
||||
throw new Error('Worktree bootstrap wait was cancelled');
|
||||
}
|
||||
|
||||
if (result.status === 'ready') {
|
||||
if (hasReachedTarget(current, target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
throw new Error(result.error || 'Worktree bootstrap failed');
|
||||
if (current.status === 'failed') {
|
||||
throw new Error(current.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
@@ -121,7 +188,8 @@ const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: n
|
||||
|
||||
const pollWorktreeBootstrapInBackground = async (
|
||||
directory: string,
|
||||
watcher: { cancelled: boolean },
|
||||
key: string,
|
||||
watcher: { cancelled: boolean; lifecycleVersion: number },
|
||||
timeoutMs: number,
|
||||
pollIntervalMs: number,
|
||||
onFailed?: WorktreeBootstrapFailureHandler,
|
||||
@@ -134,17 +202,20 @@ const pollWorktreeBootstrapInBackground = async (
|
||||
if (watcher.cancelled) {
|
||||
return;
|
||||
}
|
||||
setWorktreeBootstrapState(directory, result);
|
||||
|
||||
if (result.status === 'ready') {
|
||||
onReady?.(result);
|
||||
const current = storePolledState(key, result, watcher.lifecycleVersion);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
onFailed?.(result);
|
||||
if (current.status === 'ready') {
|
||||
onReady?.(current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.status === 'failed') {
|
||||
onFailed?.(current);
|
||||
toast.error(t('worktree.bootstrap.toast.failed'), {
|
||||
description: result.error || t('worktree.bootstrap.toast.failedDescription'),
|
||||
description: current.error || t('worktree.bootstrap.toast.failedDescription'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -183,9 +254,13 @@ export const startWorktreeBootstrapWatcher = (
|
||||
return;
|
||||
}
|
||||
|
||||
const watcher = { cancelled: false, promise: Promise.resolve() };
|
||||
watcher.promise = pollWorktreeBootstrapInBackground(
|
||||
const watcher = {
|
||||
cancelled: false,
|
||||
lifecycleVersion: lifecycleVersions.get(key) ?? 0,
|
||||
};
|
||||
void pollWorktreeBootstrapInBackground(
|
||||
directory,
|
||||
key,
|
||||
watcher,
|
||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
options?.pollIntervalMs ?? POLL_INTERVAL_MS,
|
||||
@@ -211,7 +286,11 @@ export const startWorktreeBootstrapWatcher = (
|
||||
watchers.set(key, watcher);
|
||||
};
|
||||
|
||||
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => {
|
||||
const waitForWorktreePhase = async (
|
||||
directory: string,
|
||||
target: WorktreeBootstrapTarget,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<void> => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -222,21 +301,31 @@ export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DE
|
||||
return;
|
||||
}
|
||||
|
||||
if (current?.status === 'ready') {
|
||||
if (hasReachedTarget(current, target)) {
|
||||
return;
|
||||
}
|
||||
if (current?.status === 'failed') {
|
||||
throw new Error(current.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
const existing = waiters.get(key);
|
||||
const waiterKey = getWaiterKey(key, target);
|
||||
const existing = waiters.get(waiterKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = pollWorktreeBootstrapUntilSettled(directory, timeoutMs).finally(() => {
|
||||
waiters.delete(key);
|
||||
const lifecycleVersion = lifecycleVersions.get(key) ?? 0;
|
||||
const pending = pollWorktreeBootstrapUntilSettled(directory, key, lifecycleVersion, timeoutMs, target).finally(() => {
|
||||
if (waiters.get(waiterKey) === pending) {
|
||||
waiters.delete(waiterKey);
|
||||
}
|
||||
});
|
||||
waiters.set(key, pending);
|
||||
waiters.set(waiterKey, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const waitForWorktreeGitReady = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
|
||||
waitForWorktreePhase(directory, 'git-ready', timeoutMs);
|
||||
|
||||
export const waitForWorktreeBootstrap = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
|
||||
waitForWorktreePhase(directory, 'setup-ready', timeoutMs);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { GitWorktreeCreateResult } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
type WorktreeListEntry = {
|
||||
@@ -11,16 +12,24 @@ type WorktreeListEntry = {
|
||||
const listCalls: string[] = [];
|
||||
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
|
||||
const createdWorktree = {
|
||||
head: 'abc123',
|
||||
name: 'feature',
|
||||
branch: 'feature',
|
||||
path: '/repo-feature',
|
||||
directoryCreated: true as const,
|
||||
bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 },
|
||||
};
|
||||
let createdWorktreeResult: GitWorktreeCreateResult = createdWorktree;
|
||||
const bootstrapWatcherCalls: string[] = [];
|
||||
const bootstrapWatcherOptions: Array<{ onReady?: () => void }> = [];
|
||||
|
||||
const sessionState = {
|
||||
availableWorktreesByProject: new Map<string, WorktreeMetadata[]>(),
|
||||
availableWorktrees: [] as WorktreeMetadata[],
|
||||
worktreeMetadata: new Map<string, WorktreeMetadata>(),
|
||||
};
|
||||
const attachmentState = {
|
||||
attachments: new Map<string, { worktreeStatus: 'pending' | 'ready'; worktreeRoot: string }>(),
|
||||
};
|
||||
|
||||
mock.module('@/lib/openchamberConfig', () => ({
|
||||
@@ -31,7 +40,19 @@ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
|
||||
clearWorktreeBootstrapState: mock(),
|
||||
markWorktreeBootstrapPending: mock(),
|
||||
setWorktreeBootstrapState: mock(),
|
||||
startWorktreeBootstrapWatcher: mock(),
|
||||
startWorktreeBootstrapWatcher: (directory: string, options?: { onReady?: () => void }) => {
|
||||
bootstrapWatcherCalls.push(directory);
|
||||
bootstrapWatcherOptions.push(options ?? {});
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/sync/session-worktree-store', () => ({
|
||||
useSessionWorktreeStore: {
|
||||
setState: (patch: Partial<typeof attachmentState> | ((state: typeof attachmentState) => Partial<typeof attachmentState>)) => {
|
||||
const next = typeof patch === 'function' ? patch(attachmentState) : patch;
|
||||
Object.assign(attachmentState, next);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/worktrees/worktreeStatus', () => ({
|
||||
@@ -59,13 +80,13 @@ mock.module('@/lib/gitApi', () => ({
|
||||
listResolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
create: mock(() => Promise.resolve(createdWorktree)),
|
||||
create: mock(() => Promise.resolve(createdWorktreeResult)),
|
||||
remove: mock(() => Promise.resolve({ success: true })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { createWorktree, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
|
||||
const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
|
||||
|
||||
const waitForListCallCount = async (count: number): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
@@ -81,8 +102,13 @@ describe('worktreeManager list invalidation', () => {
|
||||
beforeEach(() => {
|
||||
listCalls.length = 0;
|
||||
listResolvers.length = 0;
|
||||
bootstrapWatcherCalls.length = 0;
|
||||
bootstrapWatcherOptions.length = 0;
|
||||
createdWorktreeResult = createdWorktree;
|
||||
sessionState.availableWorktreesByProject = new Map();
|
||||
sessionState.availableWorktrees = [];
|
||||
sessionState.worktreeMetadata = new Map();
|
||||
attachmentState.attachments = new Map();
|
||||
});
|
||||
|
||||
test('retries an in-flight list when a worktree is created before it resolves', async () => {
|
||||
@@ -119,6 +145,62 @@ describe('worktreeManager list invalidation', () => {
|
||||
|
||||
expect(metadata.worktreeStatus).toBe('pending');
|
||||
expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending');
|
||||
expect(bootstrapWatcherCalls).toEqual(['/repo-feature']);
|
||||
});
|
||||
|
||||
test('treats legacy create responses without bootstrap state as fully ready', async () => {
|
||||
createdWorktreeResult = {
|
||||
head: '',
|
||||
name: 'legacy-feature',
|
||||
branch: 'legacy-feature',
|
||||
path: '/repo-legacy-feature',
|
||||
};
|
||||
|
||||
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
||||
preferredName: 'legacy-feature',
|
||||
mode: 'new',
|
||||
branchName: 'legacy-feature',
|
||||
worktreeName: 'legacy-feature',
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
|
||||
expect(metadata.worktreeStatus).toBe('ready');
|
||||
expect(bootstrapWatcherCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('reconciles session attachments when bootstrap becomes ready', async () => {
|
||||
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
||||
preferredName: 'feature',
|
||||
mode: 'new',
|
||||
branchName: 'feature',
|
||||
worktreeName: 'feature',
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
sessionState.worktreeMetadata.set('session-1', metadata);
|
||||
attachmentState.attachments.set('session-1', {
|
||||
worktreeRoot: metadata.path,
|
||||
worktreeStatus: 'pending',
|
||||
});
|
||||
|
||||
bootstrapWatcherOptions[0]?.onReady?.();
|
||||
|
||||
expect(sessionState.worktreeMetadata.get('session-1')?.worktreeStatus).toBe('ready');
|
||||
expect(attachmentState.attachments.get('session-1')?.worktreeStatus).toBe('ready');
|
||||
});
|
||||
|
||||
test('resolves ready metadata when bootstrap settles before the session is attached', async () => {
|
||||
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
||||
preferredName: 'feature',
|
||||
mode: 'new',
|
||||
branchName: 'feature',
|
||||
worktreeName: 'feature',
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
|
||||
bootstrapWatcherOptions[0]?.onReady?.();
|
||||
|
||||
expect(metadata.worktreeStatus).toBe('pending');
|
||||
expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
GitWorktreeValidationResult,
|
||||
} from '@/lib/api/types';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
||||
|
||||
type WorktreeListEntry = {
|
||||
path?: string;
|
||||
@@ -57,6 +58,18 @@ const normalizePath = (value: string): string => {
|
||||
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
||||
};
|
||||
|
||||
export const getLatestWorktreeMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => {
|
||||
const target = normalizePath(metadata.path);
|
||||
const state = useSessionUIStore.getState();
|
||||
const available = state.availableWorktrees.find((candidate) => normalizePath(candidate.path) === target);
|
||||
if (available) return available;
|
||||
for (const worktrees of state.availableWorktreesByProject.values()) {
|
||||
const candidate = worktrees.find((worktree) => normalizePath(worktree.path) === target);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const slugifyWorktreeName = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
@@ -86,6 +99,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
|
||||
return;
|
||||
}
|
||||
|
||||
const changedSessionIds: string[] = [];
|
||||
useSessionUIStore.setState((state) => {
|
||||
let changed = false;
|
||||
|
||||
@@ -131,6 +145,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
|
||||
for (const [sessionId, metadata] of state.worktreeMetadata) {
|
||||
const next = applyStatus(metadata);
|
||||
if (next !== metadata) {
|
||||
changedSessionIds.push(sessionId);
|
||||
if (worktreeMetadata === state.worktreeMetadata) {
|
||||
worktreeMetadata = new Map(state.worktreeMetadata);
|
||||
}
|
||||
@@ -148,6 +163,19 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
|
||||
worktreeMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
if (changedSessionIds.length > 0) {
|
||||
useSessionWorktreeStore.setState((state) => {
|
||||
let attachments = state.attachments;
|
||||
for (const sessionId of changedSessionIds) {
|
||||
const attachment = attachments.get(sessionId);
|
||||
if (!attachment || attachment.worktreeStatus === status) continue;
|
||||
if (attachments === state.attachments) attachments = new Map(state.attachments);
|
||||
attachments.set(sessionId, { ...attachment, worktreeStatus: status });
|
||||
}
|
||||
return attachments === state.attachments ? state : { attachments };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => {
|
||||
@@ -404,13 +432,17 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
|
||||
|
||||
if (created?.bootstrapStatus) {
|
||||
setWorktreeBootstrapState(metadata.path, created.bootstrapStatus);
|
||||
} else {
|
||||
} else if (created?.directoryCreated) {
|
||||
markWorktreeBootstrapPending(metadata.path);
|
||||
}
|
||||
startWorktreeBootstrapWatcher(metadata.path, {
|
||||
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
|
||||
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
|
||||
});
|
||||
const shouldWatchBootstrap = created?.bootstrapStatus?.status === 'pending'
|
||||
|| (!created?.bootstrapStatus && created?.directoryCreated === true);
|
||||
if (shouldWatchBootstrap) {
|
||||
startWorktreeBootstrapWatcher(metadata.path, {
|
||||
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
|
||||
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
|
||||
});
|
||||
}
|
||||
|
||||
invalidateWorktreeList(projectDirectory);
|
||||
// The new worktree changes the repo's worktree topology; drop cached root
|
||||
|
||||
Reference in New Issue
Block a user