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
549 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|