Add git worktree support for isolated parallel development (#27)

* Add git worktree support for isolated parallel development

Enable each conversation to work in an isolated git worktree via new
/worktree command (create/list/remove). Worktree path takes priority
over cwd in orchestrator's directory resolution chain.

- Add worktree_path column to conversations (migration 003)
- Add /worktree create <branch> | list | remove commands
- Update orchestrator: worktree_path > cwd > default_cwd
- Add 7 tests for worktree command coverage

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix worktree safety: prevent orphaned worktrees and respect uncommitted changes

- Check if conversation already has a worktree before creating new one
- Remove --force flag from worktree remove to let git warn about uncommitted changes
- Document git as first-class citizen in CLAUDE.md
- Add test for already-using-worktree rejection

Co-Authored-By: Claude <noreply@anthropic.com>

* Add --force flag to /worktree remove for uncommitted changes

- Add optional --force flag to discard uncommitted changes
- Provide friendly error message when worktree has uncommitted changes
- Update help text to document the --force option
- Let git's natural guardrails surface errors first, then offer escape hatch

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Wirasm 2025-12-03 10:42:24 +02:00 committed by GitHub
parent 9fbe15bbf9
commit 1add28c6e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 343 additions and 3 deletions

View file

@ -27,6 +27,13 @@
- No `any` types without explicit justification
- Interfaces for all major abstractions
**Git as First-Class Citizen**
- Let git handle what git does best (conflicts, uncommitted changes, branch management)
- Don't wrap git errors - surface them directly to users
- Trust git's natural guardrails (e.g., refuse to remove worktree with uncommitted changes)
- Use `execFileAsync` for git commands (not `exec`) to prevent command injection
- Worktrees enable parallel development per conversation without branch conflicts
## Essential Commands
### Development (Recommended)

View file

@ -0,0 +1,10 @@
-- Add worktree support to conversations
-- Version: 3.0
-- Description: Allow each conversation to work in an isolated git worktree
ALTER TABLE remote_agent_conversations
ADD COLUMN worktree_path VARCHAR(500);
-- Add comment for documentation
COMMENT ON COLUMN remote_agent_conversations.worktree_path IS
'Path to git worktree for this conversation. If set, AI works here instead of cwd.';

View file

@ -42,6 +42,7 @@ describe('conversations', () => {
ai_assistant_type: 'claude',
codebase_id: null,
cwd: null,
worktree_path: null,
created_at: new Date(),
updated_at: new Date(),
};

View file

@ -78,7 +78,7 @@ export async function getOrCreateConversation(
export async function updateConversation(
id: string,
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd'>>
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'worktree_path'>>
): Promise<void> {
const fields: string[] = [];
const values: (string | null)[] = [];
@ -92,6 +92,10 @@ export async function updateConversation(
fields.push(`cwd = $${String(i++)}`);
values.push(updates.cwd);
}
if (updates.worktree_path !== undefined) {
fields.push(`worktree_path = $${String(i++)}`);
values.push(updates.worktree_path);
}
if (fields.length === 0) {
return; // No updates

View file

@ -199,6 +199,7 @@ describe('CommandHandler', () => {
ai_assistant_type: 'claude',
codebase_id: null,
cwd: null,
worktree_path: null,
created_at: new Date(),
updated_at: new Date(),
};
@ -483,5 +484,140 @@ describe('CommandHandler', () => {
expect(result.message).toContain('Usage');
});
});
describe('/worktree', () => {
const conversationWithCodebase: Conversation = {
...baseConversation,
codebase_id: 'codebase-123',
cwd: '/workspace/my-repo',
};
beforeEach(() => {
mockCodebaseDb.getCodebase.mockResolvedValue({
id: 'codebase-123',
name: 'my-repo',
repository_url: 'https://github.com/user/my-repo',
default_cwd: '/workspace/my-repo',
ai_assistant_type: 'claude',
commands: {},
created_at: new Date(),
updated_at: new Date(),
});
});
describe('create', () => {
test('should require codebase', async () => {
const result = await handleCommand(baseConversation, '/worktree create feat-x');
expect(result.success).toBe(false);
expect(result.message).toContain('No codebase');
});
test('should require branch name', async () => {
const result = await handleCommand(conversationWithCodebase, '/worktree create');
expect(result.success).toBe(false);
expect(result.message).toContain('Usage');
});
test('should validate branch name format', async () => {
const result = await handleCommand(
conversationWithCodebase,
'/worktree create "bad name"'
);
expect(result.success).toBe(false);
expect(result.message).toContain('letters, numbers');
});
test('should create worktree with valid name', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], callback: (err: Error | null, result: { stdout: string; stderr: string }) => void) => {
callback(null, { stdout: '', stderr: '' });
}
);
mockSessionDb.getActiveSession.mockResolvedValue(null);
const result = await handleCommand(conversationWithCodebase, '/worktree create feat-auth');
expect(result.success).toBe(true);
expect(result.message).toContain('Worktree created');
expect(result.message).toContain('feat-auth');
expect(mockDb.updateConversation).toHaveBeenCalledWith(
conversationWithCodebase.id,
expect.objectContaining({ worktree_path: '/workspace/my-repo/worktrees/feat-auth' })
);
});
test('should reject if already using a worktree', async () => {
const convWithWorktree: Conversation = {
...conversationWithCodebase,
worktree_path: '/workspace/my-repo/worktrees/existing-branch',
};
const result = await handleCommand(convWithWorktree, '/worktree create new-branch');
expect(result.success).toBe(false);
expect(result.message).toContain('Already using worktree');
expect(result.message).toContain('/worktree remove first');
});
});
describe('list', () => {
test('should list worktrees', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], callback: (err: Error | null, result: { stdout: string; stderr: string }) => void) => {
callback(null, {
stdout:
'/workspace/my-repo abc1234 [main]\n/workspace/my-repo/worktrees/feat-x def5678 [feat-x]\n',
stderr: '',
});
}
);
const result = await handleCommand(conversationWithCodebase, '/worktree list');
expect(result.success).toBe(true);
expect(result.message).toContain('Worktrees:');
expect(result.message).toContain('main');
});
});
describe('remove', () => {
test('should require active worktree', async () => {
const result = await handleCommand(conversationWithCodebase, '/worktree remove');
expect(result.success).toBe(false);
expect(result.message).toContain('not using a worktree');
});
test('should remove worktree and switch to main', async () => {
const convWithWorktree: Conversation = {
...conversationWithCodebase,
worktree_path: '/workspace/my-repo/worktrees/feat-x',
};
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], callback: (err: Error | null, result: { stdout: string; stderr: string }) => void) => {
callback(null, { stdout: '', stderr: '' });
}
);
mockSessionDb.getActiveSession.mockResolvedValue(null);
const result = await handleCommand(convWithWorktree, '/worktree remove');
expect(result.success).toBe(true);
expect(result.message).toContain('removed');
expect(mockDb.updateConversation).toHaveBeenCalledWith(
convWithWorktree.id,
expect.objectContaining({ worktree_path: null, cwd: '/workspace/my-repo' })
);
});
});
describe('default', () => {
test('should show usage for unknown subcommand', async () => {
const result = await handleCommand(conversationWithCodebase, '/worktree foo');
expect(result.success).toBe(false);
expect(result.message).toContain('Usage');
});
});
});
});
});

View file

@ -103,6 +103,11 @@ Codebase:
/setcwd <path> - Set directory
Note: Use /repo for quick switching, /setcwd for manual paths
Worktrees:
/worktree create <branch> - Create isolated worktree
/worktree list - Show worktrees for this repo
/worktree remove [--force] - Remove current worktree
Session:
/status - Show state
/reset - Clear session
@ -137,6 +142,10 @@ Session:
msg += `\n\nCurrent Working Directory: ${conversation.cwd ?? 'Not set'}`;
if (conversation.worktree_path) {
msg += `\nWorktree: ${conversation.worktree_path}`;
}
const session = await sessionDb.getActiveSession(conversation.id);
if (session?.id) {
msg += `\nActive Session: ${session.id.slice(0, 8)}...`;
@ -833,6 +842,176 @@ Session:
return { success: false, message: `Template '${args[0]}' not found.` };
}
case 'worktree': {
const subcommand = args[0];
if (!conversation.codebase_id) {
return { success: false, message: 'No codebase configured. Use /clone first.' };
}
const codebase = await codebaseDb.getCodebase(conversation.codebase_id);
if (!codebase) {
return { success: false, message: 'Codebase not found.' };
}
const mainPath = codebase.default_cwd;
const worktreesDir = join(mainPath, 'worktrees');
switch (subcommand) {
case 'create': {
const branchName = args[1];
if (!branchName) {
return { success: false, message: 'Usage: /worktree create <branch-name>' };
}
// Check if already using a worktree
if (conversation.worktree_path) {
return {
success: false,
message: `Already using worktree: ${conversation.worktree_path}\n\nRun /worktree remove first.`,
};
}
// Validate branch name (alphanumeric, dash, underscore only)
if (!/^[a-zA-Z0-9_-]+$/.test(branchName)) {
return {
success: false,
message: 'Branch name must contain only letters, numbers, dashes, and underscores.',
};
}
const worktreePath = join(worktreesDir, branchName);
try {
// Create worktree with new branch
await execFileAsync('git', [
'-C',
mainPath,
'worktree',
'add',
worktreePath,
'-b',
branchName,
]);
// Add to git safe.directory
await execFileAsync('git', [
'config',
'--global',
'--add',
'safe.directory',
worktreePath,
]);
// Update conversation to use this worktree
await db.updateConversation(conversation.id, { worktree_path: worktreePath });
// Reset session for fresh start
const session = await sessionDb.getActiveSession(conversation.id);
if (session) {
await sessionDb.deactivateSession(session.id);
}
return {
success: true,
message: `Worktree created!\n\nBranch: ${branchName}\nPath: ${worktreePath}\n\nThis conversation now works in isolation.\nRun dependency install if needed (e.g., npm install).`,
modified: true,
};
} catch (error) {
const err = error as Error;
console.error('[Worktree] Create failed:', err);
// Check for common errors
if (err.message.includes('already exists')) {
return {
success: false,
message: `Branch '${branchName}' already exists. Use a different name.`,
};
}
return { success: false, message: `Failed to create worktree: ${err.message}` };
}
}
case 'list': {
try {
const { stdout } = await execFileAsync('git', ['-C', mainPath, 'worktree', 'list']);
// Parse output and mark current
const lines = stdout.trim().split('\n');
let msg = 'Worktrees:\n\n';
for (const line of lines) {
const isActive =
conversation.worktree_path && line.startsWith(conversation.worktree_path);
const marker = isActive ? ' <- active' : '';
msg += `${line}${marker}\n`;
}
return { success: true, message: msg };
} catch (error) {
const err = error as Error;
return { success: false, message: `Failed to list worktrees: ${err.message}` };
}
}
case 'remove': {
if (!conversation.worktree_path) {
return { success: false, message: 'This conversation is not using a worktree.' };
}
const worktreePath = conversation.worktree_path;
const forceFlag = args[1] === '--force';
try {
// Remove worktree (--force discards uncommitted changes)
const gitArgs = ['-C', mainPath, 'worktree', 'remove'];
if (forceFlag) {
gitArgs.push('--force');
}
gitArgs.push(worktreePath);
await execFileAsync('git', gitArgs);
// Clear worktree_path, keep cwd pointing to main repo
await db.updateConversation(conversation.id, {
worktree_path: null,
cwd: mainPath,
});
// Reset session
const session = await sessionDb.getActiveSession(conversation.id);
if (session) {
await sessionDb.deactivateSession(session.id);
}
return {
success: true,
message: `Worktree removed: ${worktreePath}\n\nSwitched back to main repo: ${mainPath}`,
modified: true,
};
} catch (error) {
const err = error as Error;
console.error('[Worktree] Remove failed:', err);
// Provide friendly error for uncommitted changes
if (err.message.includes('untracked files') || err.message.includes('modified')) {
return {
success: false,
message: 'Worktree has uncommitted changes.\n\nCommit your work first, or use `/worktree remove --force` to discard.',
};
}
return { success: false, message: `Failed to remove worktree: ${err.message}` };
}
}
default:
return {
success: false,
message: 'Usage:\n /worktree create <branch>\n /worktree list\n /worktree remove [--force]',
};
}
}
default:
return {
success: false,

View file

@ -60,6 +60,7 @@ describe('orchestrator', () => {
ai_assistant_type: 'claude',
codebase_id: 'codebase-789',
cwd: '/workspace/project',
worktree_path: null,
created_at: new Date(),
updated_at: new Date(),
};

View file

@ -76,6 +76,7 @@ export async function handleMessage(
'template-list',
'templates',
'template-delete',
'worktree',
];
if (deterministicCommands.includes(command)) {
@ -125,7 +126,7 @@ export async function handleMessage(
}
// Read command file
const cwd = conversation.cwd ?? codebase.default_cwd;
const cwd = conversation.worktree_path ?? conversation.cwd ?? codebase.default_cwd;
const commandFilePath = join(cwd, commandDef.path);
try {
@ -196,7 +197,7 @@ export async function handleMessage(
const codebase = conversation.codebase_id
? await codebaseDb.getCodebase(conversation.codebase_id)
: null;
const cwd = conversation.cwd ?? codebase?.default_cwd ?? '/workspace';
const cwd = conversation.worktree_path ?? conversation.cwd ?? codebase?.default_cwd ?? '/workspace';
// Check for plan→execute transition (requires NEW session per PRD)
// Note: The planning command is named 'plan-feature', not 'plan'

View file

@ -8,6 +8,7 @@ export interface Conversation {
platform_conversation_id: string;
codebase_id: string | null;
cwd: string | null;
worktree_path: string | null;
ai_assistant_type: string;
created_at: Date;
updated_at: Date;