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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user