From 285bb35224897dab5c913aacb85335ec35b963e2 Mon Sep 17 00:00:00 2001 From: mattv8 Date: Wed, 19 Aug 2026 12:01:16 -0600 Subject: [PATCH] fix(sessions): recheck idle tree during worktree move --- .../lib/worktrees/sessionWorktreeMove.test.ts | 44 +++++++++++++++++++ .../src/lib/worktrees/sessionWorktreeMove.ts | 7 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index a3f82e3e..59084db1 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -454,6 +454,50 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(refreshCalls).toEqual([]); }); + test('rolls back the root and never moves a child that becomes busy after the root move starts', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + const rootMove = deferred(); + const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); + const previousChildMetadata = makeWorktreeMetadata({ path: '/old-child', label: 'Old child' }); + setStatuses('/source', { root: 'idle', child: 'idle' }); + setStatuses('/destination', {}); + storedMetadata.set(root.id, previousRootMetadata); + storedMetadata.set(child.id, previousChildMetadata); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'root' && sourceDirectory === '/source') { + return rootMove.promise; + } + }; + + const movePromise = moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + }); + + await waitFor(() => moveCalls.length === 1); + setStatuses('/source', { root: 'idle', child: 'busy' }); + setStatuses('/destination', { root: 'idle' }); + rootMove.resolve(); + + await expect(movePromise).rejects.toThrow('Session is not idle'); + + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, + ]); + expect(metadataWrites).toEqual([ + { sessionId: 'root', metadata: latestMetadataResult }, + { sessionId: 'root', metadata: previousRootMetadata }, + ]); + expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); + expect(storedMetadata.get(child.id)).toBe(previousChildMetadata); + 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'); diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 0aa5499c..35da12d9 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -156,10 +156,11 @@ const moveSessionTreeTransaction = async ( const moved: Session[] = []; try { destination = await prepareDestination(); - // Setup can take long enough for one of the sessions to start running, so - // verify the whole tree again immediately before the first move. - assertSessionsIdle(sessions, input.sourceDirectory); for (const [index, session] of sessions.entries()) { + // Setup and earlier moves can take long enough for a not-yet-moved + // 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);