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 [workingDir, setWorkingDir] = useState("");
|
||||||
const [selectedMode, setSelectedMode] = useState("plan");
|
const [selectedMode, setSelectedMode] = useState("plan");
|
||||||
|
const [worktreeName, setWorktreeName] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
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(
|
const renderFooter = useCallback(
|
||||||
(props: BottomSheetFooterProps) => (
|
(props: BottomSheetFooterProps) => (
|
||||||
<BottomSheetFooter {...props} style={animatedFooterStyle}>
|
<BottomSheetFooter {...props} style={animatedFooterStyle}>
|
||||||
@@ -118,7 +207,7 @@ export function CreateAgentModal({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</BottomSheetFooter>
|
</BottomSheetFooter>
|
||||||
),
|
),
|
||||||
[insets.bottom, workingDir, animatedFooterStyle, isLoading]
|
[insets.bottom, workingDir, animatedFooterStyle, isLoading, handleCreate]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,48 +245,6 @@ export function CreateAgentModal({
|
|||||||
};
|
};
|
||||||
}, [pendingRequestId, ws]);
|
}, [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() {
|
function handleClose() {
|
||||||
bottomSheetRef.current?.dismiss();
|
bottomSheetRef.current?.dismiss();
|
||||||
}
|
}
|
||||||
@@ -207,6 +254,7 @@ export function CreateAgentModal({
|
|||||||
|
|
||||||
// Reset all state
|
// Reset all state
|
||||||
setWorkingDir("");
|
setWorkingDir("");
|
||||||
|
setWorktreeName("");
|
||||||
setSelectedMode("plan");
|
setSelectedMode("plan");
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -293,6 +341,29 @@ export function CreateAgentModal({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</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 */}
|
{/* Mode Selector */}
|
||||||
<View style={styles.formSection}>
|
<View style={styles.formSection}>
|
||||||
<Text style={styles.label}>Mode</Text>
|
<Text style={styles.label}>Mode</Text>
|
||||||
|
|||||||
@@ -110,13 +110,6 @@ class ACPClient implements Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
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
|
// Check if this update contains a Claude session ID
|
||||||
const claudeSessionId = params._meta?.claudeSessionId as string | undefined;
|
const claudeSessionId = params._meta?.claudeSessionId as string | undefined;
|
||||||
if (claudeSessionId) {
|
if (claudeSessionId) {
|
||||||
@@ -1231,9 +1224,6 @@ export class AgentManager {
|
|||||||
// Store the update in history
|
// Store the update in history
|
||||||
agent.updates.push(agentUpdate);
|
agent.updates.push(agentUpdate);
|
||||||
|
|
||||||
// Log update for debugging
|
|
||||||
console.log(`[Agent ${agentId}] Session update:`, updateType);
|
|
||||||
|
|
||||||
// Notify all subscribers
|
// Notify all subscribers
|
||||||
for (const subscriber of agent.subscribers) {
|
for (const subscriber of agent.subscribers) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
generateAgentTitle,
|
generateAgentTitle,
|
||||||
isTitleGeneratorInitialized,
|
isTitleGeneratorInitialized,
|
||||||
} from "../services/agent-title-generator.js";
|
} from "../services/agent-title-generator.js";
|
||||||
|
import { expandTilde } from "./terminal-mcp/tmux.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -166,10 +167,6 @@ export class Session {
|
|||||||
agentId,
|
agentId,
|
||||||
(update: AgentUpdate) => {
|
(update: AgentUpdate) => {
|
||||||
const notification = update.notification;
|
const notification = update.notification;
|
||||||
console.log(
|
|
||||||
`[Session ${this.clientId}] Agent update notification type:`,
|
|
||||||
notification.type
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle permission requests
|
// Handle permission requests
|
||||||
if (notification.type === "permission") {
|
if (notification.type === "permission") {
|
||||||
@@ -499,7 +496,8 @@ export class Session {
|
|||||||
await this.handleCreateAgentRequest(
|
await this.handleCreateAgentRequest(
|
||||||
msg.cwd,
|
msg.cwd,
|
||||||
msg.initialMode,
|
msg.initialMode,
|
||||||
msg.requestId
|
msg.requestId,
|
||||||
|
msg.worktreeName
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -695,7 +693,7 @@ export class Session {
|
|||||||
private async handleSendAgentAudio(
|
private async handleSendAgentAudio(
|
||||||
msg: Extract<SessionInboundMessage, { type: "send_agent_audio" }>
|
msg: Extract<SessionInboundMessage, { type: "send_agent_audio" }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { agentId, audio, format, isLast } = msg;
|
const { agentId, audio, format, isLast, requestId } = msg;
|
||||||
|
|
||||||
// Decode base64
|
// Decode base64
|
||||||
const audioBuffer = Buffer.from(audio, "base64");
|
const audioBuffer = Buffer.from(audio, "base64");
|
||||||
@@ -730,6 +728,17 @@ export class Session {
|
|||||||
`[Session ${this.clientId}] Transcribed audio for agent ${agentId}: ${transcriptText}`
|
`[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
|
// Send transcribed text to agent
|
||||||
await this.agentManager.sendPrompt(agentId, transcriptText);
|
await this.agentManager.sendPrompt(agentId, transcriptText);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -814,17 +823,44 @@ export class Session {
|
|||||||
private async handleCreateAgentRequest(
|
private async handleCreateAgentRequest(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
initialMode?: string,
|
initialMode?: string,
|
||||||
requestId?: string
|
requestId?: string,
|
||||||
|
worktreeName?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Creating agent in ${cwd} with mode ${
|
`[Session ${this.clientId}] Creating agent in ${cwd} with mode ${
|
||||||
initialMode || "default"
|
initialMode || "default"
|
||||||
}`
|
}${worktreeName ? ` with worktree ${worktreeName}` : ""}`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
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({
|
const agentId = await this.agentManager.createAgent({
|
||||||
cwd,
|
cwd: effectiveCwd,
|
||||||
initialMode,
|
initialMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,16 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
// Extract and forward session message
|
// Extract and forward session message
|
||||||
const sessionMessage = extractSessionMessage(message);
|
const sessionMessage = extractSessionMessage(message);
|
||||||
if (sessionMessage) {
|
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);
|
const session = this.sessions.get(ws);
|
||||||
if (session) {
|
if (session) {
|
||||||
await session.handleMessage(sessionMessage);
|
await session.handleMessage(sessionMessage);
|
||||||
|
|||||||
@@ -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