mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
263
packages/server/src/utils/worktree.ts
Normal file
263
packages/server/src/utils/worktree.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user