refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
This commit is contained in:
496
apps/server/git/manager.test.ts
Normal file
496
apps/server/git/manager.test.ts
Normal file
@@ -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<void>;
|
||||
}> {
|
||||
// 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<void>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user