fix(sessions): recheck idle tree during worktree move

This commit is contained in:
mattv8
2026-08-27 18:43:37 -06:00
parent 52bea6d1f3
commit 285bb35224
2 changed files with 48 additions and 3 deletions
@@ -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');
@@ -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);