/** * SimpleGitWorktreeManager Tests * * Comprehensive tests for the WorktreeManager adapter. * Uses temporary git repositories for each test. */ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { simpleGit } from 'simple-git'; import { SimpleGitWorktreeManager } from './manager.js'; import { EventEmitterBus } from '../events/bus.js'; import type { EventBus } from '../events/types.js'; /** * Create a temporary git repository for testing. * Returns the path to the repo and a cleanup function. */ async function createTestRepo(): Promise<{ repoPath: string; cleanup: () => Promise; }> { // Create temp directory const repoPath = await mkdtemp(path.join(tmpdir(), 'cw-test-repo-')); // Initialize git repo const git = simpleGit(repoPath); await git.init(); // Configure git user for commits await git.addConfig('user.email', 'test@example.com'); await git.addConfig('user.name', 'Test User'); // Create initial commit (required for worktrees) await writeFile(path.join(repoPath, 'README.md'), '# Test Repo\n'); await git.add('README.md'); await git.commit('Initial commit'); return { repoPath, cleanup: async () => { // Force remove temp directory await rm(repoPath, { recursive: true, force: true }); }, }; } describe('SimpleGitWorktreeManager', () => { let templatePath: string; let templateCleanup: () => Promise; let repoPath: string; let cleanup: () => Promise; let manager: SimpleGitWorktreeManager; let eventBus: EventBus; let emittedEvents: Array<{ type: string; payload: unknown }>; beforeAll(async () => { const testRepo = await createTestRepo(); templatePath = testRepo.repoPath; templateCleanup = testRepo.cleanup; }); afterAll(async () => { await templateCleanup(); }); beforeEach(async () => { // Clone the template repo locally (hard-links, ~10ms vs ~50ms full init) repoPath = await mkdtemp(path.join(tmpdir(), 'cw-test-repo-')); await rm(repoPath, { recursive: true, force: true }); const git = simpleGit(); await git.clone(templatePath, repoPath, ['--local']); cleanup = async () => { await rm(repoPath, { recursive: true, force: true }); }; // Create event bus and track emitted events eventBus = new EventEmitterBus(); emittedEvents = []; // Subscribe to all worktree events eventBus.on('worktree:created', (e) => emittedEvents.push({ type: e.type, payload: e.payload }) ); eventBus.on('worktree:removed', (e) => emittedEvents.push({ type: e.type, payload: e.payload }) ); eventBus.on('worktree:merged', (e) => emittedEvents.push({ type: e.type, payload: e.payload }) ); eventBus.on('worktree:conflict', (e) => emittedEvents.push({ type: e.type, payload: e.payload }) ); manager = new SimpleGitWorktreeManager(repoPath, eventBus); }); afterEach(async () => { await cleanup(); }); // ========================================================================== // CRUD Operations Tests // ========================================================================== describe('create', () => { it('creates worktree at expected path', async () => { const worktree = await manager.create('test-wt', 'feature/test'); expect(worktree.id).toBe('test-wt'); expect(worktree.branch).toBe('feature/test'); expect(worktree.path).toContain('.cw-worktrees'); expect(worktree.path).toContain('test-wt'); expect(worktree.isMainWorktree).toBe(false); }); it('creates branch from specified base', async () => { // Create a second branch first const git = simpleGit(repoPath); await git.checkoutLocalBranch('develop'); await writeFile(path.join(repoPath, 'develop.txt'), 'develop content\n'); await git.add('develop.txt'); await git.commit('Develop commit'); await git.checkout('main'); // Create worktree from develop branch const worktree = await manager.create( 'from-develop', 'feature/from-dev', 'develop' ); expect(worktree.branch).toBe('feature/from-dev'); // Verify the worktree has the develop.txt file const worktreeGit = simpleGit(worktree.path); const files = await worktreeGit.raw(['ls-files']); expect(files).toContain('develop.txt'); }); it('emits worktree:created event', async () => { await manager.create('evt-test', 'feature/event'); const createdEvents = emittedEvents.filter( (e) => e.type === 'worktree:created' ); expect(createdEvents.length).toBe(1); expect(createdEvents[0].payload).toEqual({ worktreeId: 'evt-test', branch: 'feature/event', path: expect.stringContaining('evt-test'), }); }); }); describe('remove', () => { it('removes worktree directory', async () => { const worktree = await manager.create('to-remove', 'feature/remove'); // Verify worktree exists let worktrees = await manager.list(); const found = worktrees.find((wt) => wt.id === 'to-remove'); expect(found).toBeDefined(); // Remove worktree await manager.remove('to-remove'); // Verify worktree is gone worktrees = await manager.list(); const stillFound = worktrees.find((wt) => wt.id === 'to-remove'); expect(stillFound).toBeUndefined(); }); it('emits worktree:removed event', async () => { await manager.create('remove-evt', 'feature/remove-evt'); emittedEvents = []; // Clear creation event await manager.remove('remove-evt'); const removedEvents = emittedEvents.filter( (e) => e.type === 'worktree:removed' ); expect(removedEvents.length).toBe(1); expect(removedEvents[0].payload).toEqual({ worktreeId: 'remove-evt', branch: 'feature/remove-evt', }); }); it('throws for non-existent worktree', async () => { await expect(manager.remove('non-existent')).rejects.toThrow( 'Worktree not found: non-existent' ); }); }); describe('list', () => { it('returns all worktrees', async () => { await manager.create('wt-1', 'feature/one'); await manager.create('wt-2', 'feature/two'); const worktrees = await manager.list(); // Should have main + 2 created worktrees expect(worktrees.length).toBe(3); const ids = worktrees.map((wt) => wt.id); expect(ids).toContain('main'); expect(ids).toContain('wt-1'); expect(ids).toContain('wt-2'); }); it('marks main worktree correctly', async () => { await manager.create('not-main', 'feature/not-main'); const worktrees = await manager.list(); const mainWt = worktrees.find((wt) => wt.id === 'main'); const otherWt = worktrees.find((wt) => wt.id === 'not-main'); expect(mainWt?.isMainWorktree).toBe(true); expect(otherWt?.isMainWorktree).toBe(false); }); }); describe('get', () => { it('returns worktree by id', async () => { await manager.create('get-me', 'feature/get'); const worktree = await manager.get('get-me'); expect(worktree).not.toBeNull(); expect(worktree!.id).toBe('get-me'); expect(worktree!.branch).toBe('feature/get'); }); it('returns null for non-existent id', async () => { const worktree = await manager.get('does-not-exist'); expect(worktree).toBeNull(); }); }); // ========================================================================== // Diff Operations Tests // ========================================================================== describe('diff', () => { it('returns empty for clean worktree', async () => { const worktree = await manager.create('clean-wt', 'feature/clean'); const diff = await manager.diff('clean-wt'); expect(diff.files).toEqual([]); expect(diff.summary).toBe('No changes'); }); it('detects added files', async () => { const worktree = await manager.create('add-file', 'feature/add'); // Add a new file in the worktree await writeFile(path.join(worktree.path, 'new-file.txt'), 'new content\n'); // Stage the file const worktreeGit = simpleGit(worktree.path); await worktreeGit.add('new-file.txt'); const diff = await manager.diff('add-file'); expect(diff.files.length).toBe(1); expect(diff.files[0]).toEqual({ path: 'new-file.txt', status: 'added', }); }); it('detects modified files', async () => { const worktree = await manager.create('mod-file', 'feature/mod'); // Modify existing README.md await writeFile( path.join(worktree.path, 'README.md'), '# Modified Content\n' ); const diff = await manager.diff('mod-file'); expect(diff.files.length).toBe(1); expect(diff.files[0]).toEqual({ path: 'README.md', status: 'modified', }); }); it('detects deleted files', async () => { const worktree = await manager.create('del-file', 'feature/del'); // Delete README.md using git rm const worktreeGit = simpleGit(worktree.path); await worktreeGit.rm('README.md'); const diff = await manager.diff('del-file'); expect(diff.files.length).toBe(1); expect(diff.files[0]).toEqual({ path: 'README.md', status: 'deleted', }); }); it('throws for non-existent worktree', async () => { await expect(manager.diff('no-such-wt')).rejects.toThrow( 'Worktree not found: no-such-wt' ); }); }); // ========================================================================== // Merge Operations Tests // ========================================================================== describe('merge', () => { it('succeeds with clean merge', async () => { const worktree = await manager.create('merge-clean', 'feature/merge'); // Add a new file in the worktree await writeFile( path.join(worktree.path, 'feature.txt'), 'feature content\n' ); const worktreeGit = simpleGit(worktree.path); await worktreeGit.add('feature.txt'); await worktreeGit.commit('Add feature'); // Merge back to main const result = await manager.merge('merge-clean', 'main'); expect(result.success).toBe(true); expect(result.message).toBe('Merged successfully'); expect(result.conflicts).toBeUndefined(); }); it('emits worktree:merged event on success', async () => { const worktree = await manager.create('merge-evt', 'feature/merge-evt'); // Add a file and commit await writeFile( path.join(worktree.path, 'merge-evt.txt'), 'merge event test\n' ); const worktreeGit = simpleGit(worktree.path); await worktreeGit.add('merge-evt.txt'); await worktreeGit.commit('Add merge event file'); emittedEvents = []; // Clear creation event await manager.merge('merge-evt', 'main'); const mergedEvents = emittedEvents.filter( (e) => e.type === 'worktree:merged' ); expect(mergedEvents.length).toBe(1); expect(mergedEvents[0].payload).toEqual({ worktreeId: 'merge-evt', sourceBranch: 'feature/merge-evt', targetBranch: 'main', }); }); it('detects conflicts and returns them', async () => { // Create a worktree const worktree = await manager.create( 'conflict-wt', 'feature/conflict' ); // Modify README.md in the worktree await writeFile( path.join(worktree.path, 'README.md'), '# Worktree Change\n' ); const worktreeGit = simpleGit(worktree.path); await worktreeGit.add('README.md'); await worktreeGit.commit('Worktree change'); // Also modify README.md in main (create conflict) const mainGit = simpleGit(repoPath); await writeFile(path.join(repoPath, 'README.md'), '# Main Change\n'); await mainGit.add('README.md'); await mainGit.commit('Main change'); // Try to merge - should detect conflict const result = await manager.merge('conflict-wt', 'main'); expect(result.success).toBe(false); expect(result.message).toBe('Merge conflicts detected'); expect(result.conflicts).toBeDefined(); expect(result.conflicts).toContain('README.md'); }); it('emits worktree:conflict event on conflict', async () => { // Set up conflict scenario const worktree = await manager.create( 'conflict-evt', 'feature/conflict-evt' ); // Modify README.md in worktree await writeFile( path.join(worktree.path, 'README.md'), '# Worktree Version\n' ); const worktreeGit = simpleGit(worktree.path); await worktreeGit.add('README.md'); await worktreeGit.commit('Worktree version'); // Modify README.md in main const mainGit = simpleGit(repoPath); await writeFile(path.join(repoPath, 'README.md'), '# Main Version\n'); await mainGit.add('README.md'); await mainGit.commit('Main version'); emittedEvents = []; // Clear creation event await manager.merge('conflict-evt', 'main'); const conflictEvents = emittedEvents.filter( (e) => e.type === 'worktree:conflict' ); expect(conflictEvents.length).toBe(1); expect(conflictEvents[0].payload).toEqual({ worktreeId: 'conflict-evt', sourceBranch: 'feature/conflict-evt', targetBranch: 'main', conflictingFiles: ['README.md'], }); }); it('aborts and cleans up after conflict', async () => { // Set up conflict scenario const worktree = await manager.create('abort-wt', 'feature/abort'); // Create conflict await writeFile( path.join(worktree.path, 'README.md'), '# Worktree Abort\n' ); const worktreeGit = simpleGit(worktree.path); await worktreeGit.add('README.md'); await worktreeGit.commit('Worktree abort'); const mainGit = simpleGit(repoPath); await writeFile(path.join(repoPath, 'README.md'), '# Main Abort\n'); await mainGit.add('README.md'); await mainGit.commit('Main abort'); // Merge should fail with conflicts await manager.merge('abort-wt', 'main'); // Verify repo has no merge conflicts (merge was aborted) const status = await mainGit.status(); expect(status.conflicted.length).toBe(0); // The repo should not be in a merging state expect(status.current).toBe('main'); }); it('throws for non-existent worktree', async () => { await expect(manager.merge('no-wt', 'main')).rejects.toThrow( 'Worktree not found: no-wt' ); }); }); // ========================================================================== // Cross-Agent Isolation // ========================================================================== describe('cross-agent isolation', () => { it('get() only matches worktrees in its own worktreesDir', async () => { // Simulate two agents with separate worktree base dirs but same repo const agentADir = path.join(repoPath, 'workdirs', 'agent-a'); const agentBDir = path.join(repoPath, 'workdirs', 'agent-b'); await mkdir(agentADir, { recursive: true }); await mkdir(agentBDir, { recursive: true }); const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir); const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir); // Both create worktrees with the same id (project name) await managerA.create('my-project', 'agent/agent-a'); await managerB.create('my-project', 'agent/agent-b'); // Each manager should only see its own worktree const wtA = await managerA.get('my-project'); const wtB = await managerB.get('my-project'); expect(wtA).not.toBeNull(); expect(wtB).not.toBeNull(); expect(wtA!.path).toContain('agent-a'); expect(wtB!.path).toContain('agent-b'); expect(wtA!.path).not.toBe(wtB!.path); }); it('remove() only removes worktrees in its own worktreesDir', async () => { const agentADir = path.join(repoPath, 'workdirs', 'agent-a'); const agentBDir = path.join(repoPath, 'workdirs', 'agent-b'); await mkdir(agentADir, { recursive: true }); await mkdir(agentBDir, { recursive: true }); const managerA = new SimpleGitWorktreeManager(repoPath, undefined, agentADir); const managerB = new SimpleGitWorktreeManager(repoPath, undefined, agentBDir); await managerA.create('my-project', 'agent/agent-a'); await managerB.create('my-project', 'agent/agent-b'); // Remove agent A's worktree await managerA.remove('my-project'); // Agent B's worktree should still exist const wtB = await managerB.get('my-project'); expect(wtB).not.toBeNull(); expect(wtB!.path).toContain('agent-b'); }); }); // ========================================================================== // Edge Cases // ========================================================================== describe('edge cases', () => { it('works without eventBus', async () => { // Create manager without eventBus const managerNoEvents = new SimpleGitWorktreeManager(repoPath); // Should not throw const worktree = await managerNoEvents.create( 'no-events', 'feature/no-events' ); expect(worktree.id).toBe('no-events'); await managerNoEvents.remove('no-events'); // No assertions needed - just verifying no errors }); it('handles worktree with uncommitted changes on remove', async () => { const worktree = await manager.create( 'uncommitted', 'feature/uncommitted' ); // Add uncommitted changes await writeFile( path.join(worktree.path, 'uncommitted.txt'), 'not committed\n' ); // Remove should work with --force await manager.remove('uncommitted'); const remaining = await manager.list(); const found = remaining.find((wt) => wt.id === 'uncommitted'); expect(found).toBeUndefined(); }); }); });