mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
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:
parent
9fbe15bbf9
commit
1add28c6e7
9 changed files with 343 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
10
migrations/003_add_worktree.sql
Normal file
10
migrations/003_add_worktree.sql
Normal 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.';
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue