fix(sessions): require explicit worktree change transfer
This commit is contained in:
@@ -3,6 +3,7 @@ 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';
|
||||
import type { SessionTreeMoveIntent, SessionTreeMoveMessages } from './sessionWorktreeMove';
|
||||
|
||||
const moveCalls: Array<{
|
||||
sessionId: string;
|
||||
@@ -24,6 +25,11 @@ type MoveSessionImplementation = (
|
||||
) => Promise<void>;
|
||||
type RefreshImplementation = (directories: string[]) => Promise<void>;
|
||||
type CreateQuickWorktreeOptions = { preferredName?: string; startRef?: string };
|
||||
type GitStatusResult = {
|
||||
current: string;
|
||||
isClean: boolean;
|
||||
files: Array<{ path: string; index: string; working_dir: string }>;
|
||||
};
|
||||
type CreateQuickWorktreeImplementation = (
|
||||
project: ProjectRef,
|
||||
options: CreateQuickWorktreeOptions,
|
||||
@@ -42,6 +48,7 @@ type IncompleteRollbackCause = {
|
||||
};
|
||||
|
||||
const removeWorktreeCalls: RemoveProjectWorktreeCall[] = [];
|
||||
const createQuickWorktreeCalls: Array<{ project: ProjectRef; options: CreateQuickWorktreeOptions }> = [];
|
||||
const metadataWrites: Array<{ sessionId: string; metadata: WorktreeMetadata | null }> = [];
|
||||
const toastSuccesses: string[] = [];
|
||||
const toastErrors: Array<{ title: string; description?: string }> = [];
|
||||
@@ -72,6 +79,18 @@ const sessionUIState: SessionUIState = {
|
||||
let moveSessionImplementation: MoveSessionImplementation = async () => {};
|
||||
let refreshImplementation: RefreshImplementation = async () => {};
|
||||
let latestMetadataResult: WorktreeMetadata;
|
||||
let isGitRepositoryImplementation = async (directory: string): Promise<boolean> => {
|
||||
void directory;
|
||||
return true;
|
||||
};
|
||||
let getGitStatusImplementation = async (directory: string): Promise<GitStatusResult> => {
|
||||
void directory;
|
||||
return {
|
||||
current: 'feature',
|
||||
isClean: true,
|
||||
files: [],
|
||||
};
|
||||
};
|
||||
let createQuickWorktreeImplementation: CreateQuickWorktreeImplementation = async () => ({
|
||||
path: '/created-worktree',
|
||||
projectDirectory: '/repo',
|
||||
@@ -95,7 +114,8 @@ mock.module('@/components/ui', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/lib/gitApi', () => ({
|
||||
getGitStatus: mock(() => Promise.resolve({ current: 'feature' })),
|
||||
checkIsGitRepository: (directory: string) => isGitRepositoryImplementation(directory),
|
||||
getGitStatus: (directory: string) => getGitStatusImplementation(directory),
|
||||
deleteRemoteBranch: mock(),
|
||||
git: {
|
||||
worktree: {
|
||||
@@ -119,7 +139,10 @@ mock.module('@/lib/openchamberConfig', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/lib/worktreeSessionCreator', () => ({
|
||||
createQuickWorktree: mock((project: ProjectRef, options: CreateQuickWorktreeOptions) => createQuickWorktreeImplementation(project, options)),
|
||||
createQuickWorktree: mock((project: ProjectRef, options: CreateQuickWorktreeOptions) => {
|
||||
createQuickWorktreeCalls.push({ project, options });
|
||||
return createQuickWorktreeImplementation(project, options);
|
||||
}),
|
||||
resolveProjectRef: mock((directory: string) => resolveProjectRefImplementation(directory)),
|
||||
}));
|
||||
|
||||
@@ -176,6 +199,11 @@ mock.module('@/sync/sync-refs', () => ({
|
||||
|
||||
const {
|
||||
moveSessionTreeToExistingWorktree,
|
||||
requestSessionTreeMove,
|
||||
confirmSessionTreeMove,
|
||||
cancelSessionTreeMove,
|
||||
useSessionTreeMoveConfirmation,
|
||||
getSessionTreeMoveConfirmation,
|
||||
startSessionTreeWorktreeMove,
|
||||
} = await import('./sessionWorktreeMove');
|
||||
|
||||
@@ -202,6 +230,21 @@ const makeWorktreeMetadata = (overrides: Partial<WorktreeMetadata> = {}): Worktr
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeMoveMessages = (): SessionTreeMoveMessages => ({
|
||||
success: 'move succeeded',
|
||||
failure: 'move failed',
|
||||
sourceVerificationFailed: 'source verification failed',
|
||||
applyChangesFailed: 'apply changes failed',
|
||||
});
|
||||
|
||||
const makeQuickIntent = (): SessionTreeMoveIntent => ({
|
||||
kind: 'quick',
|
||||
root: makeSession('root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
|
||||
const makeSessionStatus = (type: SessionStatus['type']): SessionStatus => {
|
||||
switch (type) {
|
||||
case 'busy':
|
||||
@@ -245,6 +288,8 @@ const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => {
|
||||
throw new Error('Expected rollback error cause details');
|
||||
}
|
||||
|
||||
// SAFETY: createIncompleteRollbackError in the module under test attaches
|
||||
// this exact cause shape when rollback reporting fails.
|
||||
const parsed = cause as Partial<IncompleteRollbackCause>;
|
||||
if (!(parsed.moveError instanceof Error)) {
|
||||
throw new Error('Expected rollback moveError cause');
|
||||
@@ -257,10 +302,9 @@ const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => {
|
||||
if (!entry || !(entry instanceof Object)) {
|
||||
throw new Error('Expected rollback failure entry');
|
||||
}
|
||||
const failure = entry as { sessionId?: unknown; error?: unknown };
|
||||
if (typeof failure.sessionId !== 'string') {
|
||||
throw new Error('Expected rollback failure session ID');
|
||||
}
|
||||
// SAFETY: the same helper populates every rollback entry with a string ID
|
||||
// and Error instance before this test helper reads it back.
|
||||
const failure = entry as { sessionId: string; error: Error };
|
||||
if (!(failure.error instanceof Error)) {
|
||||
throw new Error('Expected rollback failure error');
|
||||
}
|
||||
@@ -275,9 +319,11 @@ const getIncompleteRollbackCause = (error: Error): IncompleteRollbackCause => {
|
||||
|
||||
describe('moveSessionTreeToExistingWorktree', () => {
|
||||
beforeEach(() => {
|
||||
cancelSessionTreeMove();
|
||||
moveCalls.length = 0;
|
||||
refreshCalls.length = 0;
|
||||
removeWorktreeCalls.length = 0;
|
||||
createQuickWorktreeCalls.length = 0;
|
||||
metadataWrites.length = 0;
|
||||
toastSuccesses.length = 0;
|
||||
toastErrors.length = 0;
|
||||
@@ -289,6 +335,12 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
sessionUIState.availableWorktrees = [latestMetadataResult];
|
||||
moveSessionImplementation = async () => {};
|
||||
refreshImplementation = async () => {};
|
||||
isGitRepositoryImplementation = async () => true;
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: true,
|
||||
files: [],
|
||||
});
|
||||
createQuickWorktreeImplementation = async () => makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session' });
|
||||
resolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' });
|
||||
waitForWorktreeGitReadyImplementation = async () => {};
|
||||
@@ -314,6 +366,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [child],
|
||||
sourceDirectory: '/source',
|
||||
destination,
|
||||
moveChanges: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('/destination');
|
||||
@@ -337,6 +390,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [],
|
||||
sourceDirectory: '/source/',
|
||||
destination: makeWorktreeMetadata({ path: '/source' }),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('Source and destination are the same');
|
||||
|
||||
expect(moveCalls).toEqual([]);
|
||||
@@ -351,6 +405,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata({ worktreeStatus: 'pending' }),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('Destination worktree is not ready');
|
||||
|
||||
expect(moveCalls).toEqual([]);
|
||||
@@ -364,6 +419,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('Session is not idle');
|
||||
|
||||
expect(moveCalls).toEqual([]);
|
||||
@@ -379,6 +435,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [child],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('Session is not idle');
|
||||
|
||||
expect(moveCalls).toEqual([]);
|
||||
@@ -399,6 +456,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
});
|
||||
await waitFor(() => moveCalls.length === 1);
|
||||
|
||||
@@ -407,6 +465,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('Session move already in progress');
|
||||
|
||||
rootMove.resolve();
|
||||
@@ -436,6 +495,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [childA, childB],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('child-b failed');
|
||||
|
||||
expect(moveCalls).toEqual([
|
||||
@@ -479,6 +539,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [child],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
});
|
||||
|
||||
await waitFor(() => moveCalls.length === 1);
|
||||
@@ -520,6 +581,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [child],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
}).catch((rejection) => rejection);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
@@ -554,6 +616,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [child],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('could not be fully rolled back');
|
||||
|
||||
expect(moveCalls).toEqual([
|
||||
@@ -583,6 +646,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
});
|
||||
|
||||
expect(result).toBe('/destination');
|
||||
@@ -636,6 +700,322 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
||||
expect(moveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('moves a clean existing-worktree request without transferring source changes', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
expect(useSessionTreeMoveConfirmation).toBeDefined();
|
||||
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: makeSession('root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
|
||||
await waitFor(() => moveCalls.length === 1);
|
||||
expect(moveCalls).toEqual([{
|
||||
sessionId: 'root',
|
||||
sourceDirectory: '/source',
|
||||
destinationDirectory: '/destination',
|
||||
moveChanges: false,
|
||||
}]);
|
||||
expect(getSessionTreeMoveConfirmation()).toBeNull();
|
||||
});
|
||||
|
||||
test('waits for a dirty-source choice before preparing a quick worktree', async () => {
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [
|
||||
{ path: 'staged.ts', index: 'M', working_dir: ' ' },
|
||||
{ path: 'working.ts', index: ' ', working_dir: 'M' },
|
||||
],
|
||||
});
|
||||
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
|
||||
expect(getSessionTreeMoveConfirmation()).toEqual({
|
||||
intent: makeQuickIntent(),
|
||||
dirtyFileCount: 2,
|
||||
stagedFileCount: 1,
|
||||
});
|
||||
expect(createQuickWorktreeCalls).toEqual([]);
|
||||
expect(moveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('moves a non-Git source without checking status or transferring source changes', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
isGitRepositoryImplementation = async () => false;
|
||||
let statusCallCount = 0;
|
||||
getGitStatusImplementation = async () => {
|
||||
statusCallCount += 1;
|
||||
return {
|
||||
current: 'feature',
|
||||
isClean: true,
|
||||
files: [],
|
||||
};
|
||||
};
|
||||
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
|
||||
await waitFor(() => createQuickWorktreeCalls.length === 1);
|
||||
await waitFor(() => moveCalls.length === 1);
|
||||
|
||||
expect(statusCallCount).toBe(0);
|
||||
expect(moveCalls).toEqual([{
|
||||
sessionId: 'root',
|
||||
sourceDirectory: '/source',
|
||||
destinationDirectory: '/created-worktree',
|
||||
moveChanges: false,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('uses the source verification failure message when the repository check fails', async () => {
|
||||
isGitRepositoryImplementation = async () => {
|
||||
throw new Error('repo check failed');
|
||||
};
|
||||
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
|
||||
expect(createQuickWorktreeCalls).toEqual([]);
|
||||
expect(moveCalls).toEqual([]);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'source verification failed' }]);
|
||||
});
|
||||
|
||||
test('uses the source verification failure message when the status check fails', async () => {
|
||||
getGitStatusImplementation = async () => {
|
||||
throw new Error('status failed');
|
||||
};
|
||||
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
|
||||
expect(createQuickWorktreeCalls).toEqual([]);
|
||||
expect(moveCalls).toEqual([]);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'source verification failed' }]);
|
||||
});
|
||||
|
||||
test('cancels a pending dirty-source request without starting setup or move', async () => {
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
|
||||
cancelSessionTreeMove();
|
||||
|
||||
expect(getSessionTreeMoveConfirmation()).toBeNull();
|
||||
expect(createQuickWorktreeCalls).toEqual([]);
|
||||
expect(moveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('confirms session-only mode after a dirty-source request', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
|
||||
confirmSessionTreeMove(false);
|
||||
|
||||
await waitFor(() => moveCalls.length === 1);
|
||||
|
||||
expect(getSessionTreeMoveConfirmation()).toBeNull();
|
||||
expect(moveCalls).toEqual([{
|
||||
sessionId: 'root',
|
||||
sourceDirectory: '/source',
|
||||
destinationDirectory: '/created-worktree',
|
||||
moveChanges: false,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('confirms all changes for the root but not descendants after a dirty-source request', async () => {
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
setStatuses('/source', { root: 'idle', child: 'idle' });
|
||||
|
||||
requestSessionTreeMove({
|
||||
kind: 'quick',
|
||||
root: makeSession('root'),
|
||||
descendants: [makeSession('child')],
|
||||
sourceDirectory: '/source',
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => moveCalls.length === 2);
|
||||
|
||||
expect(getSessionTreeMoveConfirmation()).toBeNull();
|
||||
expect(moveCalls).toEqual([
|
||||
{
|
||||
sessionId: 'root',
|
||||
sourceDirectory: '/source',
|
||||
destinationDirectory: '/created-worktree',
|
||||
moveChanges: true,
|
||||
},
|
||||
{
|
||||
sessionId: 'child',
|
||||
sourceDirectory: '/source',
|
||||
destinationDirectory: '/created-worktree',
|
||||
moveChanges: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not replace an existing pending dirty-source confirmation', async () => {
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
|
||||
requestSessionTreeMove(makeQuickIntent());
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
|
||||
const firstConfirmation = getSessionTreeMoveConfirmation();
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: makeSession('other-root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/other-source',
|
||||
destination: makeWorktreeMetadata({ path: '/other-destination' }),
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
|
||||
expect(getSessionTreeMoveConfirmation()).toBe(firstConfirmation);
|
||||
expect(createQuickWorktreeCalls).toEqual([]);
|
||||
expect(moveCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('uses session-only mode when rolling back a moved root', async () => {
|
||||
const root = makeSession('root');
|
||||
const child = makeSession('child');
|
||||
setStatuses('/source', { root: 'idle', child: 'idle' });
|
||||
setStatuses('/destination', { root: 'idle' });
|
||||
moveSessionImplementation = async (session, sourceDirectory) => {
|
||||
if (session.id === 'child' && sourceDirectory === '/source') {
|
||||
throw new Error('child failed');
|
||||
}
|
||||
};
|
||||
|
||||
await expect(moveSessionTreeToExistingWorktree({
|
||||
root,
|
||||
descendants: [child],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: false,
|
||||
})).rejects.toThrow('child failed');
|
||||
|
||||
expect(moveCalls).toEqual([
|
||||
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
|
||||
{ sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
|
||||
{ sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses all-changes mode when rolling back a moved root after a full transfer', async () => {
|
||||
const root = makeSession('root');
|
||||
const child = makeSession('child');
|
||||
setStatuses('/source', { root: 'idle', child: 'idle' });
|
||||
setStatuses('/destination', { root: 'idle' });
|
||||
moveSessionImplementation = async (session, sourceDirectory) => {
|
||||
if (session.id === 'child' && sourceDirectory === '/source') {
|
||||
throw new Error('child failed');
|
||||
}
|
||||
};
|
||||
|
||||
await expect(moveSessionTreeToExistingWorktree({
|
||||
root,
|
||||
descendants: [child],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
moveChanges: true,
|
||||
})).rejects.toThrow('child failed');
|
||||
|
||||
expect(moveCalls).toEqual([
|
||||
{ sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true },
|
||||
{ sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false },
|
||||
{ sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses actionable apply guidance for explicit transfer failures', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
const error = Object.assign(new Error('Unable to apply your changes in the destination directory: fix conflicts'), { status: 400 });
|
||||
moveSessionImplementation = async (session, sourceDirectory) => {
|
||||
if (session.id === 'root' && sourceDirectory === '/source') {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: makeSession('root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'apply changes failed' }]);
|
||||
});
|
||||
|
||||
test('retains other move errors when a 400 failure is not the apply-changes case', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
getGitStatusImplementation = async () => ({
|
||||
current: 'feature',
|
||||
isClean: false,
|
||||
files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }],
|
||||
});
|
||||
const error = Object.assign(new Error('Destination directory belongs to another project'), { status: 400 });
|
||||
moveSessionImplementation = async (session, sourceDirectory) => {
|
||||
if (session.id === 'root' && sourceDirectory === '/source') {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: makeSession('root'),
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: makeWorktreeMetadata(),
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
|
||||
await waitFor(() => getSessionTreeMoveConfirmation() !== null);
|
||||
confirmSessionTreeMove(true);
|
||||
|
||||
await waitFor(() => toastErrors.length === 1);
|
||||
expect(toastErrors).toEqual([{ title: 'move failed', description: 'Destination directory belongs to another project' }]);
|
||||
});
|
||||
|
||||
test('surfaces a pre-destination preparation failure without attempting removal', async () => {
|
||||
setStatuses('/source', { root: 'idle' });
|
||||
resolveProjectRefImplementation = () => null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getGitStatus } from '@/lib/gitApi';
|
||||
import { checkIsGitRepository, getGitStatus } from '@/lib/gitApi';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
|
||||
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
@@ -12,12 +12,60 @@ import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useSessionMoveState = create<{ pendingSessionIds: Set<string> }>(() => ({
|
||||
export type SessionTreeMoveMessages = {
|
||||
success: string;
|
||||
failure: string;
|
||||
sourceVerificationFailed: string;
|
||||
applyChangesFailed: string;
|
||||
};
|
||||
|
||||
export type SessionTreeMoveIntent =
|
||||
| {
|
||||
kind: 'existing';
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
destination: WorktreeMetadata;
|
||||
messages: SessionTreeMoveMessages;
|
||||
}
|
||||
| {
|
||||
kind: 'quick';
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
messages: SessionTreeMoveMessages;
|
||||
};
|
||||
|
||||
export type SessionTreeMoveConfirmation = {
|
||||
intent: SessionTreeMoveIntent;
|
||||
dirtyFileCount: number;
|
||||
stagedFileCount: number;
|
||||
};
|
||||
|
||||
type SessionMoveState = {
|
||||
pendingSessionIds: Set<string>;
|
||||
requestingSessionIds: Set<string>;
|
||||
confirmation: SessionTreeMoveConfirmation | null;
|
||||
};
|
||||
|
||||
const useSessionMoveState = create<SessionMoveState>(() => ({
|
||||
pendingSessionIds: new Set(),
|
||||
requestingSessionIds: new Set(),
|
||||
confirmation: null,
|
||||
}));
|
||||
|
||||
export const useIsSessionWorktreeMovePending = (sessionId: string): boolean =>
|
||||
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId));
|
||||
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId) || state.requestingSessionIds.has(sessionId));
|
||||
|
||||
export const useSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null =>
|
||||
useSessionMoveState((state) => state.confirmation);
|
||||
|
||||
export const getSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null =>
|
||||
useSessionMoveState.getState().confirmation;
|
||||
|
||||
const setSessionMoveConfirmation = (confirmation: SessionTreeMoveConfirmation | null): void => {
|
||||
useSessionMoveState.setState((state) => (state.confirmation === confirmation ? state : { ...state, confirmation }));
|
||||
};
|
||||
|
||||
const setSessionMovePending = (sessionId: string, pending: boolean): void => {
|
||||
useSessionMoveState.setState((state) => {
|
||||
@@ -25,10 +73,29 @@ const setSessionMovePending = (sessionId: string, pending: boolean): void => {
|
||||
const pendingSessionIds = new Set(state.pendingSessionIds);
|
||||
if (pending) pendingSessionIds.add(sessionId);
|
||||
else pendingSessionIds.delete(sessionId);
|
||||
return { pendingSessionIds };
|
||||
return { ...state, pendingSessionIds };
|
||||
});
|
||||
};
|
||||
|
||||
const setSessionMoveRequesting = (sessionId: string, requesting: boolean): void => {
|
||||
useSessionMoveState.setState((state) => {
|
||||
if (state.requestingSessionIds.has(sessionId) === requesting) return state;
|
||||
const requestingSessionIds = new Set(state.requestingSessionIds);
|
||||
if (requesting) requestingSessionIds.add(sessionId);
|
||||
else requestingSessionIds.delete(sessionId);
|
||||
return { ...state, requestingSessionIds };
|
||||
});
|
||||
};
|
||||
|
||||
const APPLY_CHANGES_MESSAGE = 'Unable to apply your changes in the destination directory';
|
||||
|
||||
const isApplyChangesError = (error: Error): boolean => {
|
||||
// SAFETY: move failures originate from our own SDK/runtime layer, which may
|
||||
// attach an optional numeric HTTP status to an Error instance.
|
||||
const errorWithStatus = error as Error & { status?: number };
|
||||
return errorWithStatus.status === 400 && error.message.includes(APPLY_CHANGES_MESSAGE);
|
||||
};
|
||||
|
||||
const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise<string> => {
|
||||
try {
|
||||
const status = await getGitStatus(directory, { mode: 'light' });
|
||||
@@ -87,6 +154,7 @@ const rollbackMovedSessions = async (
|
||||
sourceDirectory: string,
|
||||
worktreeDirectory: string,
|
||||
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
|
||||
moveChanges: boolean,
|
||||
): Promise<RollbackFailure[]> => {
|
||||
const failures: RollbackFailure[] = [];
|
||||
for (const session of [...sessions].reverse()) {
|
||||
@@ -95,12 +163,12 @@ const rollbackMovedSessions = async (
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
worktreeDirectory,
|
||||
sourceDirectory,
|
||||
session.id === rootSessionId,
|
||||
);
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
worktreeDirectory,
|
||||
sourceDirectory,
|
||||
session.id === rootSessionId && moveChanges,
|
||||
);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
@@ -130,6 +198,7 @@ const moveSessionTreeTransaction = async (
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
moveChanges: boolean;
|
||||
},
|
||||
prepareDestination: () => Promise<{
|
||||
directory: string;
|
||||
@@ -161,9 +230,12 @@ const moveSessionTreeTransaction = async (
|
||||
// descendant to start running, so re-check the remaining source tree
|
||||
// immediately before each move.
|
||||
assertSessionsIdle(sessions.slice(index), input.sourceDirectory);
|
||||
// Transfer the checkout changes once with the root. Descendants only
|
||||
// need their execution location updated.
|
||||
await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, index === 0);
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
input.sourceDirectory,
|
||||
destination.directory,
|
||||
index === 0 && input.moveChanges,
|
||||
);
|
||||
moved.push(session);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata));
|
||||
}
|
||||
@@ -175,6 +247,7 @@ const moveSessionTreeTransaction = async (
|
||||
input.sourceDirectory,
|
||||
destination?.directory ?? input.sourceDirectory,
|
||||
previousMetadata,
|
||||
input.moveChanges,
|
||||
);
|
||||
if (rollbackFailures.length > 0) {
|
||||
throw createIncompleteRollbackError(moveError, rollbackFailures);
|
||||
@@ -203,6 +276,7 @@ export const moveSessionTreeToExistingWorktree = async (input: {
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
destination: WorktreeMetadata;
|
||||
moveChanges: boolean;
|
||||
}): Promise<string> => {
|
||||
const normalizedSourceDirectory = normalizePath(input.sourceDirectory) ?? input.sourceDirectory;
|
||||
const normalizedDestinationDirectory = normalizePath(input.destination.path) ?? input.destination.path;
|
||||
@@ -223,13 +297,16 @@ const moveSessionTreeToQuickWorktree = async (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
moveChanges: boolean;
|
||||
}): 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 });
|
||||
const sourceBranch = await checkIsGitRepository(input.sourceDirectory)
|
||||
? await resolveSourceBranch(input.sourceDirectory, project.path)
|
||||
: null;
|
||||
const worktree = await createQuickWorktree(project, sourceBranch ? { startRef: sourceBranch } : {});
|
||||
try {
|
||||
await waitForWorktreeGitReady(worktree.path);
|
||||
} catch (error) {
|
||||
@@ -244,6 +321,91 @@ const moveSessionTreeToQuickWorktree = async (input: {
|
||||
});
|
||||
};
|
||||
|
||||
const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: boolean): void => {
|
||||
const movePromise = intent.kind === 'existing'
|
||||
? moveSessionTreeToExistingWorktree({
|
||||
root: intent.root,
|
||||
descendants: intent.descendants,
|
||||
sourceDirectory: intent.sourceDirectory,
|
||||
destination: intent.destination,
|
||||
moveChanges,
|
||||
})
|
||||
: moveSessionTreeToQuickWorktree({
|
||||
root: intent.root,
|
||||
descendants: intent.descendants,
|
||||
sourceDirectory: intent.sourceDirectory,
|
||||
moveChanges,
|
||||
});
|
||||
|
||||
void movePromise
|
||||
.then(() => toast.success(intent.messages.success))
|
||||
.catch((error) => {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
toast.error(intent.messages.failure, {
|
||||
description: moveChanges && isApplyChangesError(failure)
|
||||
? intent.messages.applyChangesFailed
|
||||
: failure.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const cancelSessionTreeMove = (): void => {
|
||||
const confirmation = getSessionTreeMoveConfirmation();
|
||||
if (!confirmation) return;
|
||||
setSessionMoveRequesting(confirmation.intent.root.id, false);
|
||||
setSessionMoveConfirmation(null);
|
||||
};
|
||||
|
||||
export const confirmSessionTreeMove = (moveChanges: boolean): void => {
|
||||
const confirmation = getSessionTreeMoveConfirmation();
|
||||
if (!confirmation) return;
|
||||
const { intent } = confirmation;
|
||||
setSessionMoveConfirmation(null);
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
executeSessionTreeMove(intent, moveChanges);
|
||||
};
|
||||
|
||||
export const requestSessionTreeMove = (intent: SessionTreeMoveIntent): void => {
|
||||
const state = useSessionMoveState.getState();
|
||||
if (state.confirmation) return;
|
||||
if (state.pendingSessionIds.has(intent.root.id) || state.requestingSessionIds.has(intent.root.id)) return;
|
||||
|
||||
setSessionMoveRequesting(intent.root.id, true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const isGitRepository = await checkIsGitRepository(intent.sourceDirectory);
|
||||
if (!isGitRepository) {
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
executeSessionTreeMove(intent, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getGitStatus(intent.sourceDirectory);
|
||||
if (status.isClean) {
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
executeSessionTreeMove(intent, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stagedFileCount = status.files.filter((file) => {
|
||||
const indexStatus = file.index.trim();
|
||||
return indexStatus !== '' && indexStatus !== '?';
|
||||
}).length;
|
||||
setSessionMoveConfirmation({
|
||||
intent,
|
||||
dirtyFileCount: status.files.length,
|
||||
stagedFileCount,
|
||||
});
|
||||
} catch {
|
||||
toast.error(intent.messages.failure, {
|
||||
description: intent.messages.sourceVerificationFailed,
|
||||
});
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
export const startSessionTreeExistingWorktreeMove = (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
@@ -252,11 +414,19 @@ export const startSessionTreeExistingWorktreeMove = (input: {
|
||||
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),
|
||||
}));
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: input.root,
|
||||
descendants: input.descendants,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
destination: input.destination,
|
||||
messages: {
|
||||
success: input.successMessage,
|
||||
failure: input.failureMessage,
|
||||
sourceVerificationFailed: input.failureMessage,
|
||||
applyChangesFailed: input.failureMessage,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const startSessionTreeWorktreeMove = (input: {
|
||||
@@ -266,9 +436,16 @@ export const startSessionTreeWorktreeMove = (input: {
|
||||
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),
|
||||
}));
|
||||
requestSessionTreeMove({
|
||||
kind: 'quick',
|
||||
root: input.root,
|
||||
descendants: input.descendants,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
messages: {
|
||||
success: input.successMessage,
|
||||
failure: input.failureMessage,
|
||||
sourceVerificationFailed: input.failureMessage,
|
||||
applyChangesFailed: input.failureMessage,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user