2026-06-03 15:16:36 +03:00
|
|
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
2026-07-19 00:00:31 +03:00
|
|
|
import type { GitWorktreeCreateResult } from '@/lib/api/types';
|
2026-06-03 15:16:36 +03:00
|
|
|
import type { WorktreeMetadata } from '@/types/worktree';
|
|
|
|
|
|
|
|
|
|
type WorktreeListEntry = {
|
|
|
|
|
path?: string;
|
|
|
|
|
branch?: string;
|
|
|
|
|
head?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const listCalls: string[] = [];
|
|
|
|
|
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
|
|
|
|
|
const createdWorktree = {
|
2026-07-19 00:00:31 +03:00
|
|
|
head: 'abc123',
|
2026-06-03 15:16:36 +03:00
|
|
|
name: 'feature',
|
|
|
|
|
branch: 'feature',
|
|
|
|
|
path: '/repo-feature',
|
2026-06-06 23:22:16 +03:00
|
|
|
directoryCreated: true as const,
|
|
|
|
|
bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 },
|
2026-06-03 15:16:36 +03:00
|
|
|
};
|
2026-07-19 00:00:31 +03:00
|
|
|
let createdWorktreeResult: GitWorktreeCreateResult = createdWorktree;
|
|
|
|
|
const bootstrapWatcherCalls: string[] = [];
|
|
|
|
|
const bootstrapWatcherOptions: Array<{ onReady?: () => void }> = [];
|
2026-06-03 15:16:36 +03:00
|
|
|
|
|
|
|
|
const sessionState = {
|
|
|
|
|
availableWorktreesByProject: new Map<string, WorktreeMetadata[]>(),
|
|
|
|
|
availableWorktrees: [] as WorktreeMetadata[],
|
2026-07-19 00:00:31 +03:00
|
|
|
worktreeMetadata: new Map<string, WorktreeMetadata>(),
|
|
|
|
|
};
|
|
|
|
|
const attachmentState = {
|
|
|
|
|
attachments: new Map<string, { worktreeStatus: 'pending' | 'ready'; worktreeRoot: string }>(),
|
2026-06-03 15:16:36 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mock.module('@/lib/openchamberConfig', () => ({
|
|
|
|
|
substituteCommandVariables: (command: string) => command,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
|
|
|
|
|
clearWorktreeBootstrapState: mock(),
|
|
|
|
|
markWorktreeBootstrapPending: mock(),
|
2026-06-06 23:22:16 +03:00
|
|
|
setWorktreeBootstrapState: mock(),
|
2026-07-19 00:00:31 +03:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-06-03 15:16:36 +03:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
mock.module('@/lib/worktrees/worktreeStatus', () => ({
|
|
|
|
|
invalidateResolvedProjectRootCache: mock(),
|
|
|
|
|
resolveProjectRoot: (directory: string) => Promise.resolve(directory),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
mock.module('@/sync/session-ui-store', () => ({
|
|
|
|
|
useSessionUIStore: {
|
|
|
|
|
getState: () => sessionState,
|
|
|
|
|
setState: (patch: Partial<typeof sessionState> | ((state: typeof sessionState) => Partial<typeof sessionState>)) => {
|
|
|
|
|
const next = typeof patch === 'function' ? patch(sessionState) : patch;
|
|
|
|
|
Object.assign(sessionState, next);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
mock.module('@/lib/gitApi', () => ({
|
|
|
|
|
deleteRemoteBranch: mock(),
|
|
|
|
|
git: {
|
|
|
|
|
worktree: {
|
|
|
|
|
list: (directory: string) => {
|
|
|
|
|
listCalls.push(directory);
|
|
|
|
|
return new Promise<WorktreeListEntry[]>((resolve) => {
|
|
|
|
|
listResolvers.push(resolve);
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-07-19 00:00:31 +03:00
|
|
|
create: mock(() => Promise.resolve(createdWorktreeResult)),
|
2026-06-03 15:16:36 +03:00
|
|
|
remove: mock(() => Promise.resolve({ success: true })),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
2026-07-19 00:00:31 +03:00
|
|
|
const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
|
2026-06-03 15:16:36 +03:00
|
|
|
|
|
|
|
|
const waitForListCallCount = async (count: number): Promise<void> => {
|
|
|
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
|
|
|
if (listCalls.length >= count) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`Expected ${count} worktree list calls, got ${listCalls.length}`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('worktreeManager list invalidation', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
listCalls.length = 0;
|
|
|
|
|
listResolvers.length = 0;
|
2026-07-19 00:00:31 +03:00
|
|
|
bootstrapWatcherCalls.length = 0;
|
|
|
|
|
bootstrapWatcherOptions.length = 0;
|
|
|
|
|
createdWorktreeResult = createdWorktree;
|
2026-06-03 15:16:36 +03:00
|
|
|
sessionState.availableWorktreesByProject = new Map();
|
|
|
|
|
sessionState.availableWorktrees = [];
|
2026-07-19 00:00:31 +03:00
|
|
|
sessionState.worktreeMetadata = new Map();
|
|
|
|
|
attachmentState.attachments = new Map();
|
2026-06-03 15:16:36 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('retries an in-flight list when a worktree is created before it resolves', async () => {
|
|
|
|
|
const project = { id: 'project-1', path: '/repo' };
|
|
|
|
|
const listing = listProjectWorktrees(project);
|
|
|
|
|
|
|
|
|
|
await waitForListCallCount(1);
|
|
|
|
|
|
|
|
|
|
await createWorktree(project, {
|
|
|
|
|
preferredName: 'feature',
|
|
|
|
|
mode: 'new',
|
|
|
|
|
branchName: 'feature',
|
|
|
|
|
worktreeName: 'feature',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
listResolvers[0]([]);
|
|
|
|
|
await waitForListCallCount(2);
|
|
|
|
|
listResolvers[1]([createdWorktree]);
|
|
|
|
|
|
|
|
|
|
const result = await listing;
|
|
|
|
|
|
|
|
|
|
expect(listCalls).toEqual(['/repo', '/repo']);
|
|
|
|
|
expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
|
|
|
|
});
|
2026-06-06 23:22:16 +03:00
|
|
|
|
|
|
|
|
test('marks fast-created worktrees pending until bootstrap settles', async () => {
|
|
|
|
|
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
|
|
|
|
preferredName: 'feature',
|
|
|
|
|
mode: 'new',
|
|
|
|
|
branchName: 'feature',
|
|
|
|
|
worktreeName: 'feature',
|
|
|
|
|
returnAfterDirectoryCreated: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(metadata.worktreeStatus).toBe('pending');
|
|
|
|
|
expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending');
|
2026-07-19 00:00:31 +03:00
|
|
|
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');
|
2026-06-06 23:22:16 +03:00
|
|
|
});
|
2026-06-03 15:16:36 +03:00
|
|
|
});
|
2026-07-12 00:06:58 +11:00
|
|
|
|
|
|
|
|
describe('worktreeMapsEqual', () => {
|
|
|
|
|
const wt = (
|
|
|
|
|
path: string,
|
|
|
|
|
branch: string,
|
|
|
|
|
overrides: Partial<WorktreeMetadata> = {},
|
|
|
|
|
): WorktreeMetadata => ({
|
|
|
|
|
path,
|
|
|
|
|
branch,
|
|
|
|
|
projectDirectory: '/repo',
|
|
|
|
|
label: branch,
|
|
|
|
|
...overrides,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns true for two empty maps', () => {
|
|
|
|
|
const a = new Map<string, WorktreeMetadata[]>();
|
|
|
|
|
const b = new Map<string, WorktreeMetadata[]>();
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns true when paths and branches match in order', () => {
|
|
|
|
|
const a = new Map([['/repo', [wt('/r/main', 'main'), wt('/r/feat', 'feat')]]]);
|
|
|
|
|
const b = new Map([['/repo', [wt('/r/main', 'main'), wt('/r/feat', 'feat')]]]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns false when same path has a different branch (external git checkout)', () => {
|
|
|
|
|
const a = new Map([['/repo', [wt('/r/main', 'main')]]]);
|
|
|
|
|
const b = new Map([['/repo', [wt('/r/main', 'develop')]]]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 00:49:28 +03:00
|
|
|
test('returns false when head state changes without a branch change', () => {
|
|
|
|
|
const a = new Map([['/repo', [wt('/r/main', '', { headState: 'unborn' })]]]);
|
|
|
|
|
const b = new Map([['/repo', [wt('/r/main', '', { headState: 'detached' })]]]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns false when discovered display metadata changes', () => {
|
|
|
|
|
const a = new Map([['/repo', [wt('/r/main', '', { name: 'old', label: 'old' })]]]);
|
|
|
|
|
const b = new Map([['/repo', [wt('/r/main', '', { name: 'new', label: 'new' })]]]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 00:06:58 +11:00
|
|
|
test('returns false when paths differ', () => {
|
|
|
|
|
const a = new Map([['/repo', [wt('/r/main', 'main')]]]);
|
|
|
|
|
const b = new Map([['/repo', [wt('/r/other', 'main')]]]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns false when per-project array lengths differ', () => {
|
|
|
|
|
const a = new Map([['/repo', [wt('/r/main', 'main')]]]);
|
|
|
|
|
const b = new Map([['/repo', [wt('/r/main', 'main'), wt('/r/feat', 'feat')]]]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns false when number of project keys differ', () => {
|
|
|
|
|
const a = new Map<string, WorktreeMetadata[]>([['/repo', [wt('/r/main', 'main')]]]);
|
|
|
|
|
const b = new Map<string, WorktreeMetadata[]>([
|
|
|
|
|
['/repo', [wt('/r/main', 'main')]],
|
|
|
|
|
['/repo-2', [wt('/r2/main', 'main')]],
|
|
|
|
|
]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns false when worktrees are reordered (positional compare)', () => {
|
|
|
|
|
const a = new Map([['/repo', [wt('/r/main', 'main'), wt('/r/feat', 'feat')]]]);
|
|
|
|
|
const b = new Map([['/repo', [wt('/r/feat', 'feat'), wt('/r/main', 'main')]]]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns false when a non-first worktree differs (subset of entries)', () => {
|
|
|
|
|
const a = new Map([
|
|
|
|
|
['/repo', [wt('/r/main', 'main'), wt('/r/feat', 'feat'), wt('/r/old', 'old')]],
|
|
|
|
|
]);
|
|
|
|
|
const b = new Map([
|
|
|
|
|
['/repo', [wt('/r/main', 'main'), wt('/r/feat', 'feat'), wt('/r/old', 'new-branch')]],
|
|
|
|
|
]);
|
|
|
|
|
expect(worktreeMapsEqual(a, b)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|