Files
Codewalkers/apps/server/git/manager.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

549 lines
18 KiB
TypeScript

/**
* 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'
);
});
});
// ==========================================================================
// 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();
});
});
});