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
co-authored by Claude
parent 4323d7d193
commit dcfdccf07f
5 changed files with 432 additions and 62 deletions
+263
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,
};
}