Files
Codewalkers/apps/server/git/manager.test.ts
Lukas May 4a7105eb8f fix: Worktree get() matches wrong agent due to ambiguous endsWith lookup
The `get(id)` method on SimpleGitWorktreeManager used `path.endsWith(id)`
to find worktrees. Since all agents working on the same project create
worktrees with the same project name suffix (e.g., "codewalk-district"),
cleanup for one agent could match and delete another agent's worktree.

Fix: match on `basename(worktreesDir)/id` so each manager's lookups are
scoped to its own worktree base directory.
2026-03-06 14:52:28 +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();
});
});
});