feat(sessions): move sessions to existing worktrees
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user