fix(worktree): implement worktree creation for agent isolation

- Add worktree utility functions for git worktree management
- Fix stale closure issue in create-agent-modal causing worktreeName to not be sent
- Refactor create-agent-modal to use useCallback for all functions in proper order
- Add tilde path expansion before worktree creation to fix "Not in a git repository" error
- Add worktreeName parameter to create_agent_request WebSocket message
- Add debug logging for worktree creation flow
- Remove noisy logging (message chunk updates, session updates, agent update notifications)

The worktree feature allows creating isolated git worktrees for each agent,
enabling work on different branches simultaneously without conflicts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-10-26 13:28:05 +01:00
parent 4323d7d193
commit dcfdccf07f
5 changed files with 432 additions and 62 deletions

View File

@@ -60,6 +60,7 @@ export function CreateAgentModal({
const [workingDir, setWorkingDir] = useState("");
const [selectedMode, setSelectedMode] = useState("plan");
const [worktreeName, setWorktreeName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
@@ -92,6 +93,94 @@ export function CreateAgentModal({
[]
);
// Slugify helper function
const slugifyWorktreeName = useCallback((input: string): string => {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}, []);
// Validate worktree name
const validateWorktreeName = useCallback((name: string): { valid: boolean; error?: string } => {
if (!name) return { valid: true }; // Optional field
if (name.length > 100) {
return { valid: false, error: "Worktree name too long (max 100 characters)" };
}
const validPattern = /^[a-z0-9-]+$/;
if (!validPattern.test(name)) {
return {
valid: false,
error: "Must contain only lowercase letters, numbers, and hyphens",
};
}
if (name.startsWith("-") || name.endsWith("-")) {
return { valid: false, error: "Cannot start or end with a hyphen" };
}
if (name.includes("--")) {
return { valid: false, error: "Cannot have consecutive hyphens" };
}
return { valid: true };
}, []);
const handleCreate = useCallback(async () => {
if (!workingDir.trim()) {
setErrorMessage("Working directory is required");
return;
}
if (isLoading) {
return;
}
const path = workingDir.trim();
const worktree = worktreeName.trim();
// Validate worktree name if provided
if (worktree) {
const validation = validateWorktreeName(worktree);
if (!validation.valid) {
setErrorMessage(`Invalid worktree name: ${validation.error}`);
return;
}
}
// Save to recent paths
try {
await addRecentPath(path);
} catch (error) {
console.error("[CreateAgentModal] Failed to save recent path:", error);
// Continue anyway - don't block agent creation
}
// Generate request ID
const requestId = generateMessageId();
setIsLoading(true);
setPendingRequestId(requestId);
setErrorMessage("");
// Create the agent
try {
createAgent({
cwd: path,
initialMode: selectedMode,
worktreeName: worktree || undefined,
requestId,
});
} catch (error) {
console.error("[CreateAgentModal] Failed to create agent:", error);
setErrorMessage("Failed to create agent. Please try again.");
setIsLoading(false);
setPendingRequestId(null);
}
}, [workingDir, worktreeName, selectedMode, isLoading, validateWorktreeName, addRecentPath, createAgent]);
const renderFooter = useCallback(
(props: BottomSheetFooterProps) => (
<BottomSheetFooter {...props} style={animatedFooterStyle}>
@@ -118,7 +207,7 @@ export function CreateAgentModal({
</Animated.View>
</BottomSheetFooter>
),
[insets.bottom, workingDir, animatedFooterStyle, isLoading]
[insets.bottom, workingDir, animatedFooterStyle, isLoading, handleCreate]
);
useEffect(() => {
@@ -156,48 +245,6 @@ export function CreateAgentModal({
};
}, [pendingRequestId, ws]);
async function handleCreate() {
if (!workingDir.trim()) {
setErrorMessage("Working directory is required");
return;
}
if (isLoading) {
return;
}
const path = workingDir.trim();
// Save to recent paths
try {
await addRecentPath(path);
} catch (error) {
console.error("[CreateAgentModal] Failed to save recent path:", error);
// Continue anyway - don't block agent creation
}
// Generate request ID
const requestId = generateMessageId();
setIsLoading(true);
setPendingRequestId(requestId);
setErrorMessage("");
// Create the agent
try {
createAgent({
cwd: path,
initialMode: selectedMode,
requestId,
});
} catch (error) {
console.error("[CreateAgentModal] Failed to create agent:", error);
setErrorMessage("Failed to create agent. Please try again.");
setIsLoading(false);
setPendingRequestId(null);
}
}
function handleClose() {
bottomSheetRef.current?.dismiss();
}
@@ -207,6 +254,7 @@ export function CreateAgentModal({
// Reset all state
setWorkingDir("");
setWorktreeName("");
setSelectedMode("plan");
setErrorMessage("");
setIsLoading(false);
@@ -293,6 +341,29 @@ export function CreateAgentModal({
)}
</View>
{/* Worktree Name Input (Optional) */}
<View style={styles.formSection}>
<Text style={styles.label}>Worktree Name (Optional)</Text>
<BottomSheetTextInput
style={[styles.input, isLoading && styles.inputDisabled]}
placeholder="feature-branch-name"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={worktreeName}
onChangeText={(text) => {
// Auto-slugify as user types
const slugified = slugifyWorktreeName(text);
setWorktreeName(slugified);
setErrorMessage("");
}}
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
<Text style={styles.helperText}>
Create a git worktree for isolated development. Must be lowercase, alphanumeric, and hyphens only.
</Text>
</View>
{/* Mode Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Mode</Text>

View File

@@ -110,13 +110,6 @@ class ACPClient implements Client {
}
async sessionUpdate(params: SessionNotification): Promise<void> {
if (params.update.sessionUpdate === "agent_message_chunk") {
console.log(
`[Agent ${this.agentId}] Message chunk update:`,
JSON.stringify(params, null, 2)
);
}
// Check if this update contains a Claude session ID
const claudeSessionId = params._meta?.claudeSessionId as string | undefined;
if (claudeSessionId) {
@@ -1231,9 +1224,6 @@ export class AgentManager {
// Store the update in history
agent.updates.push(agentUpdate);
// Log update for debugging
console.log(`[Agent ${agentId}] Session update:`, updateType);
// Notify all subscribers
for (const subscriber of agent.subscribers) {
try {

View File

@@ -33,6 +33,7 @@ import {
generateAgentTitle,
isTitleGeneratorInitialized,
} from "../services/agent-title-generator.js";
import { expandTilde } from "./terminal-mcp/tmux.js";
const execAsync = promisify(exec);
@@ -166,10 +167,6 @@ export class Session {
agentId,
(update: AgentUpdate) => {
const notification = update.notification;
console.log(
`[Session ${this.clientId}] Agent update notification type:`,
notification.type
);
// Handle permission requests
if (notification.type === "permission") {
@@ -499,7 +496,8 @@ export class Session {
await this.handleCreateAgentRequest(
msg.cwd,
msg.initialMode,
msg.requestId
msg.requestId,
msg.worktreeName
);
break;
@@ -695,7 +693,7 @@ export class Session {
private async handleSendAgentAudio(
msg: Extract<SessionInboundMessage, { type: "send_agent_audio" }>
): Promise<void> {
const { agentId, audio, format, isLast } = msg;
const { agentId, audio, format, isLast, requestId } = msg;
// Decode base64
const audioBuffer = Buffer.from(audio, "base64");
@@ -730,6 +728,17 @@ export class Session {
`[Session ${this.clientId}] Transcribed audio for agent ${agentId}: ${transcriptText}`
);
// Emit transcription result to client with requestId
this.emit({
type: "transcription_result",
payload: {
text: result.text,
language: result.language,
duration: result.duration,
requestId,
},
});
// Send transcribed text to agent
await this.agentManager.sendPrompt(agentId, transcriptText);
console.log(
@@ -814,17 +823,44 @@ export class Session {
private async handleCreateAgentRequest(
cwd: string,
initialMode?: string,
requestId?: string
requestId?: string,
worktreeName?: string
): Promise<void> {
console.log(
`[Session ${this.clientId}] Creating agent in ${cwd} with mode ${
initialMode || "default"
}`
}${worktreeName ? ` with worktree ${worktreeName}` : ""}`
);
try {
let effectiveCwd = cwd;
// Handle worktree creation if requested
if (worktreeName) {
const { createWorktree } = await import("../utils/worktree.js");
console.log(
`[Session ${this.clientId}] Creating worktree with branch: ${worktreeName}`
);
// Expand tilde in path before passing to createWorktree
const expandedCwd = expandTilde(cwd);
console.log(
`[Session ${this.clientId}] Expanded cwd from ${cwd} to ${expandedCwd}`
);
const worktreeConfig = await createWorktree({
branchName: worktreeName,
cwd: expandedCwd,
});
effectiveCwd = worktreeConfig.worktreePath;
console.log(
`[Session ${this.clientId}] Worktree created at: ${effectiveCwd}`
);
}
const agentId = await this.agentManager.createAgent({
cwd,
cwd: effectiveCwd,
initialMode,
});

View File

@@ -160,6 +160,16 @@ export class VoiceAssistantWebSocketServer {
// Extract and forward session message
const sessionMessage = extractSessionMessage(message);
if (sessionMessage) {
// Debug: Log create_agent_request details
if (sessionMessage.type === "create_agent_request") {
console.log("[WS] create_agent_request details:", {
cwd: sessionMessage.cwd,
initialMode: sessionMessage.initialMode,
worktreeName: sessionMessage.worktreeName,
requestId: sessionMessage.requestId,
});
}
const session = this.sessions.get(ws);
if (session) {
await session.handleMessage(sessionMessage);

View File

@@ -0,0 +1,263 @@
import { exec } from "child_process";
import { promisify } from "util";
import { existsSync, readFileSync } from "fs";
import { join, basename, dirname } from "path";
const execAsync = promisify(exec);
interface RepoInfo {
type: "bare" | "normal";
path: string;
name: string;
}
interface WorktreeConfig {
branchName: string;
worktreePath: string;
repoType: "bare" | "normal";
repoPath: string;
}
/**
* Check if current directory is a bare repository
*/
function isBareRepo(dir: string): boolean {
const headPath = join(dir, "HEAD");
const refsPath = join(dir, "refs");
const gitPath = join(dir, ".git");
return existsSync(headPath) && existsSync(refsPath) && !existsSync(gitPath);
}
/**
* Detect repository information (type, path, name)
*/
export async function detectRepoInfo(cwd: string): Promise<RepoInfo> {
// Check if we're in a bare repository
if (isBareRepo(cwd)) {
return {
type: "bare",
path: cwd,
name: basename(cwd),
};
}
// Check if we're in a worktree directory (has .git file pointing to gitdir)
const gitFilePath = join(cwd, ".git");
if (existsSync(gitFilePath) && !existsSync(join(cwd, ".git", "HEAD"))) {
const gitContent = readFileSync(gitFilePath, "utf8");
const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/);
if (gitdirMatch && gitdirMatch[1]) {
const gitdir = gitdirMatch[1].trim();
// Check if gitdir contains .git/worktrees
if (gitdir.includes("/.git/worktrees/")) {
// Extract the repo path (everything before /.git/worktrees/)
const repoRoot = gitdir.split("/.git/worktrees/")[0];
if (repoRoot) {
return {
type: "normal",
path: repoRoot,
name: basename(repoRoot),
};
}
} else {
// Bare repo structure - worktrees are siblings
const worktreesDir = dirname(gitdir);
const bareRepoDir = dirname(worktreesDir);
return {
type: "bare",
path: bareRepoDir,
name: basename(bareRepoDir),
};
}
}
}
// Check if we're in a normal git repository
const gitDirPath = join(cwd, ".git");
if (existsSync(gitDirPath)) {
try {
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
cwd,
});
const repoRoot = stdout.trim();
return {
type: "normal",
path: repoRoot,
name: basename(repoRoot),
};
} catch (error) {
throw new Error("Failed to determine git repository root");
}
}
throw new Error("Not in a git repository");
}
/**
* Validate that a string is a valid git branch name slug
* Must be lowercase, alphanumeric, hyphens only
*/
export function validateBranchSlug(slug: string): {
valid: boolean;
error?: string;
} {
if (!slug || slug.length === 0) {
return { valid: false, error: "Branch name cannot be empty" };
}
if (slug.length > 100) {
return { valid: false, error: "Branch name too long (max 100 characters)" };
}
// Check for valid characters: lowercase letters, numbers, hyphens
const validPattern = /^[a-z0-9-]+$/;
if (!validPattern.test(slug)) {
return {
valid: false,
error:
"Branch name must contain only lowercase letters, numbers, and hyphens",
};
}
// Cannot start or end with hyphen
if (slug.startsWith("-") || slug.endsWith("-")) {
return {
valid: false,
error: "Branch name cannot start or end with a hyphen",
};
}
// Cannot have consecutive hyphens
if (slug.includes("--")) {
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
}
return { valid: true };
}
/**
* Convert string to kebab-case for branch names
*/
export function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Check if a worktree already exists for a branch
*/
async function findExistingWorktree(
branchName: string,
repoPath: string
): Promise<string | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: repoPath,
});
const lines = stdout.split("\n");
let currentWorktree: string | null = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
currentWorktree = line.substring("worktree ".length);
} else if (
line === `branch refs/heads/${branchName}` &&
currentWorktree
) {
return currentWorktree;
}
}
return null;
} catch (error) {
return null;
}
}
/**
* Create a git worktree with proper naming conventions
*/
export async function createWorktree(params: {
branchName: string;
cwd: string;
}): Promise<WorktreeConfig> {
const { branchName, cwd } = params;
// Validate branch name
const validation = validateBranchSlug(branchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
// Detect repository info
const repoInfo = await detectRepoInfo(cwd);
// Determine worktree directory based on repo type
let worktreePath: string;
if (repoInfo.type === "bare") {
worktreePath = join(repoInfo.path, branchName);
} else {
const parentDir = dirname(repoInfo.path);
const worktreeName = `${repoInfo.name}-${branchName.replace(/\//g, "-")}`;
worktreePath = join(parentDir, worktreeName);
}
// Check if branch already exists
try {
await execAsync(
`git show-ref --verify --quiet refs/heads/${branchName}`,
{ cwd: repoInfo.path }
);
// Branch exists, check for existing worktree
const existingWorktree = await findExistingWorktree(
branchName,
repoInfo.path
);
if (existingWorktree) {
if (existsSync(existingWorktree)) {
throw new Error(
`Worktree already exists at: ${existingWorktree}. Use 'git worktree remove ${existingWorktree}' to remove it first.`
);
} else {
// Prune stale worktree reference
await execAsync("git worktree prune", { cwd: repoInfo.path });
}
}
// Create worktree using existing branch
await execAsync(`git worktree add "${worktreePath}" "${branchName}"`, {
cwd: repoInfo.path,
});
} catch (error) {
// Branch doesn't exist, create new branch and worktree
await execAsync(
`git worktree add "${worktreePath}" -b "${branchName}"`,
{ cwd: repoInfo.path }
);
}
// Copy .env file if it exists
const envSource =
repoInfo.type === "bare"
? join(repoInfo.path, "main", ".env")
: join(repoInfo.path, ".env");
if (existsSync(envSource)) {
await execAsync(`cp "${envSource}" "${worktreePath}/.env"`);
}
return {
branchName,
worktreePath,
repoType: repoInfo.type,
repoPath: repoInfo.path,
};
}