From 0d4b3f036a0b5aae55bd03e0a3658cb37d91765e Mon Sep 17 00:00:00 2001 From: mattv8 Date: Thu, 20 Aug 2026 12:55:25 -0600 Subject: [PATCH] fix(sessions): require explicit worktree change transfer --- .../lib/worktrees/sessionWorktreeMove.test.ts | 392 +++++++++++++++++- .../src/lib/worktrees/sessionWorktreeMove.ts | 227 ++++++++-- 2 files changed, 588 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index 8a0beb3a..3e61b4ec 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -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; type RefreshImplementation = (directories: string[]) => Promise; 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 => { + void directory; + return true; +}; +let getGitStatusImplementation = async (directory: string): Promise => { + 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 = {}): 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; 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; diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 35da12d9..57f773ca 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -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 }>(() => ({ +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; + requestingSessionIds: Set; + confirmation: SessionTreeMoveConfirmation | null; +}; + +const useSessionMoveState = create(() => ({ 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 => { try { const status = await getGitStatus(directory, { mode: 'light' }); @@ -87,6 +154,7 @@ const rollbackMovedSessions = async ( sourceDirectory: string, worktreeDirectory: string, previousMetadata: ReadonlyMap, + moveChanges: boolean, ): Promise => { 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 => { 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 => { 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, + }, + }); };