UNPKG

@adamhancock/worktree

Version:

Git worktree manager with branch selection, dependency installation, and VS Code integration

508 lines (439 loc) 17 kB
#!/usr/bin/env node import { $, echo, chalk, fs, argv } from 'zx'; import { join, dirname, resolve } from 'path'; import { select, input } from '@inquirer/prompts'; import { homedir } from 'os'; $.verbose = false; interface WorktreeConfig { vscode?: { args?: string[]; command?: string; open?: boolean; // Whether to open VS Code at all }; worktree?: { prefix?: string; location?: string; // Custom location pattern, e.g., "../worktrees/{prefix}-{branch}" }; git?: { fetch?: boolean; // Whether to fetch before creating worktree (default: true) remote?: string; // Remote name (default: "origin") defaultBranch?: string; // Default branch for new branches (default: "main") pushNewBranches?: boolean; // Auto-push new branches (default: false) }; env?: { copy?: boolean; // Whether to copy env files (default: true) patterns?: string[]; // Patterns for env files (default: [".env*"]) exclude?: string[]; // Patterns to exclude }; packageManager?: { install?: boolean; // Whether to auto-install (default: true) force?: 'npm' | 'yarn' | 'pnpm' | 'bun'; // Force specific package manager command?: string; // Custom install command }; hooks?: { postCreate?: string[]; // Commands to run after creation }; } async function loadConfig(): Promise<WorktreeConfig> { const config: WorktreeConfig = {}; // Look for config files in order of precedence const configPaths = [ join(process.cwd(), '.worktreerc.json'), join(process.cwd(), '.worktreerc'), join(homedir(), '.worktreerc.json'), join(homedir(), '.worktreerc') ]; for (const configPath of configPaths) { if (fs.existsSync(configPath)) { try { const fileContent = fs.readFileSync(configPath, 'utf-8'); const parsedConfig = JSON.parse(fileContent); Object.assign(config, parsedConfig); echo(chalk.gray(`Loaded config from: ${configPath}`)); break; } catch (err) { echo(chalk.yellow(`Warning: Failed to parse config file ${configPath}`)); } } } return config; } async function configureShell() { try { await $`which zsh`; $.shell = '/bin/zsh'; echo(chalk.gray('Using zsh shell')); } catch { $.shell = '/bin/bash'; echo(chalk.gray('Using bash shell (zsh not available)')); } } async function isGitRepository(): Promise<boolean> { try { await $`git rev-parse --git-dir`; return true; } catch { return false; } } async function getRemoteBranches(remote: string = 'origin'): Promise<string[]> { echo(chalk.blue('Fetching latest branches...')); await $`git fetch ${remote}`; const result = await $`git branch -r`; const branches = result.stdout .split('\n') .filter(line => line.trim() && !line.includes('HEAD')) .map(line => line.trim().replace(`${remote}/`, '')) .sort(); return branches; } async function selectBranchInteractive(branches: string[]): Promise<string | null> { try { const CREATE_NEW = '+ Create new branch'; const choices = [ { name: CREATE_NEW, value: CREATE_NEW }, ...branches.map(branch => ({ name: branch, value: branch })) ]; const selected = await select({ message: 'Select a branch to create worktree:', choices }); if (selected === CREATE_NEW) { const branchName = await input({ message: 'Enter the new branch name:', validate: (value) => { if (!value.trim()) { return 'Branch name cannot be empty'; } if (branches.includes(value)) { return 'Branch already exists'; } return true; } }); echo(chalk.green(`\nCreating new branch: ${branchName}`)); return branchName; } echo(chalk.cyan(`\nSelected branch: ${selected}`)); return selected; } catch (err) { echo('\nCancelled'); return null; } } async function findEnvFiles(dir: string, patterns: string[] = ['.env*'], exclude: string[] = []): Promise<string[]> { let allFiles: string[] = []; // Execute find command for each pattern separately to avoid shell interpretation issues for (const pattern of patterns) { try { const result = await $`find ${dir} -name ${pattern} -type f`; const files = result.stdout.split('\n').filter(Boolean); allFiles.push(...files); } catch (err) { // Pattern might not match any files, that's ok } } // Remove duplicates allFiles = [...new Set(allFiles)]; // Apply exclusions if (exclude.length > 0) { allFiles = allFiles.filter(file => { const filename = file.split('/').pop() || ''; return !exclude.some(pattern => { const regex = new RegExp(pattern.replace(/\*/g, '.*')); return regex.test(filename); }); }); } return allFiles; } async function branchExists(branchName: string, type: 'local' | 'remote', remote: string = 'origin'): Promise<boolean> { try { if (type === 'local') { await $`git show-ref --verify --quiet refs/heads/${branchName}`; } else { await $`git show-ref --verify --quiet refs/remotes/${remote}/${branchName}`; } return true; } catch { return false; } } async function detectPackageManager(dir: string): Promise<string> { if (fs.existsSync(join(dir, 'pnpm-lock.yaml'))) { return 'pnpm'; } if (fs.existsSync(join(dir, 'yarn.lock'))) { return 'yarn'; } if (fs.existsSync(join(dir, 'package-lock.json'))) { return 'npm'; } if (fs.existsSync(join(dir, 'bun.lockb'))) { return 'bun'; } return 'npm'; // default } async function installDependencies(packageManager: string, workingDir?: string) { echo(chalk.blue(`Installing dependencies with ${packageManager}...`)); const originalVerbose = $.verbose; const originalCwd = $.cwd; $.verbose = true; // Show command output try { if (workingDir) { $.cwd = workingDir; } await $`pwd`; switch (packageManager) { case 'pnpm': await $`pnpm install --frozen-lockfile`; break; case 'yarn': await $`yarn install --frozen-lockfile`; break; case 'bun': await $`bun install --frozen-lockfile`; break; case 'npm': default: await $`npm ci`; break; } } finally { $.verbose = originalVerbose; // Restore original verbose setting $.cwd = originalCwd; // Restore original working directory } } async function createWorktree(branchName?: string) { // Configure shell await configureShell(); // Load configuration const config = await loadConfig(); // Check for --no-vscode flag const noVscode = argv['no-vscode'] || argv['novscode'] || false; if (noVscode) { config.vscode = { ...config.vscode, open: false }; } // Check if we're in a git repository if (!await isGitRepository()) { echo(chalk.red('Error: Not in a git repository')); process.exit(1); } let selectedBranch: string; const remote = config.git?.remote || 'origin'; const defaultBranch = config.git?.defaultBranch || 'main'; // If no branch provided, show interactive selector if (!branchName) { const branches = await getRemoteBranches(remote); if (branches.length === 0) { echo(chalk.red('No remote branches found')); process.exit(1); } const selected = await selectBranchInteractive(branches); if (!selected) { process.exit(0); } selectedBranch = selected; } else { selectedBranch = branchName; } // Replace forward slashes with hyphens for directory name const safeBranchName = selectedBranch.replace(/\//g, '-'); const prefix = config.worktree?.prefix || ''; // Use custom location pattern if provided let worktreePath: string; if (config.worktree?.location) { worktreePath = config.worktree.location .replace('{prefix}', prefix) .replace('{branch}', safeBranchName) .replace('{original-branch}', selectedBranch); } else { worktreePath = join('..', `${prefix}-${safeBranchName}`); } echo(chalk.cyan(`Creating worktree for branch: ${selectedBranch}`)); echo(chalk.gray(`Worktree path: ${worktreePath}`)); // Check if worktree directory already exists if (fs.existsSync(worktreePath)) { echo(chalk.red(`Error: Directory ${worktreePath} already exists`)); process.exit(1); } // Store the original directory const originalDir = process.cwd(); // Fetch latest from remote (skip if it would cause conflicts or disabled) if (config.git?.fetch !== false) { echo(chalk.blue(`Fetching latest from ${remote}...`)); try { await $`git fetch ${remote}`; } catch (err) { echo(chalk.yellow(`Warning: Could not fetch from ${remote} (this is OK if ${defaultBranch} is checked out elsewhere)`)); } // Pull the latest changes for the default branch echo(chalk.blue(`Pulling latest changes for ${defaultBranch} branch...`)); try { // Get current branch const currentBranchResult = await $`git rev-parse --abbrev-ref HEAD`; const currentBranch = currentBranchResult.stdout.trim(); if (currentBranch === defaultBranch) { // If we're on the default branch, just pull await $`git pull ${remote} ${defaultBranch}`; } else { // If we're on a different branch, fetch and update the default branch await $`git fetch ${remote} ${defaultBranch}:${defaultBranch}`; } echo(chalk.green(`Successfully updated ${defaultBranch} branch`)); } catch (err) { echo(chalk.yellow(`Warning: Could not pull latest changes for ${defaultBranch}: ${err instanceof Error ? err.message : String(err)}`)); } } // Create the worktree with the appropriate branch try { if (await branchExists(selectedBranch, 'local', remote)) { echo(chalk.blue(`Creating worktree with existing local branch: ${selectedBranch}`)); await $`git worktree add ${worktreePath} ${selectedBranch}`; } else if (await branchExists(selectedBranch, 'remote', remote)) { echo(chalk.blue(`Creating worktree from remote branch: ${selectedBranch}`)); await $`git worktree add ${worktreePath} -b ${selectedBranch} ${remote}/${selectedBranch}`; } else { echo(chalk.blue(`Creating worktree with new branch: ${selectedBranch}`)); // Create new branch from remote/defaultBranch to avoid checkout conflicts await $`git worktree add ${worktreePath} -b ${selectedBranch} ${remote}/${defaultBranch}`; } } catch (err) { echo(chalk.red(`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`)); process.exit(1); } // Get absolute path and change to the worktree directory const absoluteWorktreePath = resolve(originalDir, worktreePath); process.chdir(absoluteWorktreePath); // Wait a moment for git to settle after worktree creation await new Promise(resolve => setTimeout(resolve, 100)); // Set up branch tracking for the worktree try { if (await branchExists(selectedBranch, 'remote', remote)) { // For existing remote branches, ensure tracking is set echo(chalk.blue(`Setting upstream for ${selectedBranch} to track ${remote}/${selectedBranch}`)); await $`git branch --set-upstream-to=${remote}/${selectedBranch} ${selectedBranch}`; } else { // For new branches, set up push configuration echo(chalk.blue(`New branch ${selectedBranch} created locally. Configuring to push to ${remote}/${selectedBranch}`)); await $`git config branch.${selectedBranch}.remote ${remote}`; await $`git config branch.${selectedBranch}.merge refs/heads/${selectedBranch}`; await $`git config push.default simple`; // Auto-push new branches if configured if (config.git?.pushNewBranches) { echo(chalk.blue(`Pushing new branch to ${remote}...`)); try { await $`git push -u ${remote} ${selectedBranch}`; echo(chalk.green(`Successfully pushed ${selectedBranch} to ${remote}`)); } catch (err) { echo(chalk.yellow(`Warning: Could not push new branch: ${err instanceof Error ? err.message : String(err)}`)); } } } } catch (err) { echo(chalk.yellow(`Warning: Could not set upstream tracking: ${err instanceof Error ? err.message : String(err)}`)); } // Stay in the worktree directory for subsequent operations // Navigate to the new worktree (already changed during upstream setup) // No need to change directory again // Copy .env files from the original directory if (config.env?.copy !== false) { echo(chalk.blue('Copying .env files...')); const patterns = config.env?.patterns || ['.env*']; const exclude = config.env?.exclude || []; const envFiles = await findEnvFiles(originalDir, patterns, exclude); for (const envFile of envFiles) { const relativePath = envFile.replace(originalDir + '/', ''); const targetPath = join(process.cwd(), relativePath); const targetDir = dirname(targetPath); // Create directory structure if it doesn't exist await $`mkdir -p ${targetDir}`; // Copy the file (we're already in the worktree directory) await $`cp ${envFile} ${targetPath}`; echo(chalk.green(`Copied: ${relativePath}`)); } } else { echo(chalk.gray('Skipping .env file copying (disabled in config)')); } // Detect package manager and install dependencies if (config.packageManager?.install !== false) { echo(chalk.gray(`Current directory: ${process.cwd()}`)); const packageManager = config.packageManager?.force || await detectPackageManager(process.cwd()); if (config.packageManager?.command) { echo(chalk.blue(`Running custom install command: ${config.packageManager.command}`)); const originalVerbose = $.verbose; $.verbose = true; try { await $`${config.packageManager.command.split(' ')}`; } finally { $.verbose = originalVerbose; } } else { await installDependencies(packageManager, process.cwd()); } } else { echo(chalk.gray('Skipping dependency installation (disabled in config)')); } // Run post-create hooks if (config.hooks?.postCreate && config.hooks.postCreate.length > 0) { echo(chalk.blue('Running post-create hooks...')); echo(chalk.gray(`Working directory: ${process.cwd()}`)); // Verify we're in the correct worktree const currentBranch = await $`git rev-parse --abbrev-ref HEAD`; echo(chalk.gray(`Current branch: ${currentBranch.stdout.trim()}`)); const originalVerbose = $.verbose; $.verbose = true; // Show hook command output // Prepare template variables for replacement const templateVars: Record<string, string> = { branch: selectedBranch, safeBranch: safeBranchName, worktreePath: absoluteWorktreePath, originalDir: originalDir, prefix: prefix, remote: remote, defaultBranch: defaultBranch }; for (const hook of config.hooks.postCreate) { // Replace template variables in the hook command let expandedHook = hook; for (const [key, value] of Object.entries(templateVars)) { expandedHook = expandedHook.replace(new RegExp(`\\{${key}\\}`, 'g'), value); } echo(chalk.gray(`Running: ${expandedHook}`)); echo(chalk.gray(`Running in directory: ${process.cwd()}`)); try { // Ensure we're in the worktree directory and use cd to explicitly set the directory for the eval await $`cd ${absoluteWorktreePath} && eval ${expandedHook}`; } catch (err) { echo(chalk.yellow(`Warning: Hook failed: ${expandedHook}`)); echo(chalk.yellow(`Error: ${err instanceof Error ? err.message : String(err)}`)); } } $.verbose = originalVerbose; // Restore original verbose setting } // Open in VS Code (after hooks have completed) if (config.vscode?.open !== false) { const absoluteWorktreePath = resolve(originalDir, worktreePath); echo(chalk.blue(`Opening VS Code at: ${absoluteWorktreePath}`)); try { const vscodeCommand = config.vscode?.command || 'code'; const vscodeArgs = config.vscode?.args || []; const command = [vscodeCommand, ...vscodeArgs, absoluteWorktreePath]; await $`${command}`; } catch (err) { echo(chalk.yellow('Failed to open VS Code. You can manually open the project at:'), absoluteWorktreePath); } } echo(chalk.green('✅ Worktree created successfully!')); echo(chalk.cyan(`📁 Location: ${worktreePath}`)); echo(chalk.cyan(`🌿 Branch: ${selectedBranch}`)); } // Main execution const branchName = argv._[0]; createWorktree(branchName).catch(err => { echo(chalk.red('Error:'), err.message); process.exit(1); });