From c32bc553d0ee0d9fff476c3deced289f16fb968a Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 19:30:30 +0100 Subject: [PATCH] test(03-02): add comprehensive tests for WorktreeManager adapter - 23 tests covering all WorktreeManager operations - Test setup creates temp git repos with initial commit - CRUD tests: create, remove, list, get operations - Diff tests: clean worktree, added/modified/deleted files - Merge tests: clean merge, conflict detection, abort/cleanup - Event emission tests for all 4 worktree events - Edge case tests: no eventBus, uncommitted changes on remove --- src/git/manager.test.ts | 496 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 src/git/manager.test.ts diff --git a/src/git/manager.test.ts b/src/git/manager.test.ts new file mode 100644 index 0000000..41006dc --- /dev/null +++ b/src/git/manager.test.ts @@ -0,0 +1,496 @@ +/** + * SimpleGitWorktreeManager Tests + * + * Comprehensive tests for the WorktreeManager adapter. + * Uses temporary git repositories for each test. + */ + +import { describe, it, expect, beforeEach, afterEach } 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 repoPath: string; + let cleanup: () => Promise; + let manager: SimpleGitWorktreeManager; + let eventBus: EventBus; + let emittedEvents: Array<{ type: string; payload: unknown }>; + + beforeEach(async () => { + const testRepo = await createTestRepo(); + repoPath = testRepo.repoPath; + cleanup = testRepo.cleanup; + + // 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' + ); + }); + }); + + // ========================================================================== + // 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(); + }); + }); +});