UNPKG

@adamhancock/worktree

Version:

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

437 lines (436 loc) 18.6 kB
#!/usr/bin/env node "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const zx_1 = require("zx"); const path_1 = require("path"); const prompts_1 = require("@inquirer/prompts"); const os_1 = require("os"); zx_1.$.verbose = false; async function loadConfig() { const config = {}; // Look for config files in order of precedence const configPaths = [ (0, path_1.join)(process.cwd(), '.worktreerc.json'), (0, path_1.join)(process.cwd(), '.worktreerc'), (0, path_1.join)((0, os_1.homedir)(), '.worktreerc.json'), (0, path_1.join)((0, os_1.homedir)(), '.worktreerc') ]; for (const configPath of configPaths) { if (zx_1.fs.existsSync(configPath)) { try { const fileContent = zx_1.fs.readFileSync(configPath, 'utf-8'); const parsedConfig = JSON.parse(fileContent); Object.assign(config, parsedConfig); (0, zx_1.echo)(zx_1.chalk.gray(`Loaded config from: ${configPath}`)); break; } catch (err) { (0, zx_1.echo)(zx_1.chalk.yellow(`Warning: Failed to parse config file ${configPath}`)); } } } return config; } async function configureShell() { try { await (0, zx_1.$) `which zsh`; zx_1.$.shell = '/bin/zsh'; (0, zx_1.echo)(zx_1.chalk.gray('Using zsh shell')); } catch { zx_1.$.shell = '/bin/bash'; (0, zx_1.echo)(zx_1.chalk.gray('Using bash shell (zsh not available)')); } } async function isGitRepository() { try { await (0, zx_1.$) `git rev-parse --git-dir`; return true; } catch { return false; } } async function getRemoteBranches(remote = 'origin') { (0, zx_1.echo)(zx_1.chalk.blue('Fetching latest branches...')); await (0, zx_1.$) `git fetch ${remote}`; const result = await (0, zx_1.$) `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) { 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 (0, prompts_1.select)({ message: 'Select a branch to create worktree:', choices }); if (selected === CREATE_NEW) { const branchName = await (0, prompts_1.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; } }); (0, zx_1.echo)(zx_1.chalk.green(`\nCreating new branch: ${branchName}`)); return branchName; } (0, zx_1.echo)(zx_1.chalk.cyan(`\nSelected branch: ${selected}`)); return selected; } catch (err) { (0, zx_1.echo)('\nCancelled'); return null; } } async function findEnvFiles(dir, patterns = ['.env*'], exclude = []) { let allFiles = []; // Execute find command for each pattern separately to avoid shell interpretation issues for (const pattern of patterns) { try { const result = await (0, zx_1.$) `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, type, remote = 'origin') { try { if (type === 'local') { await (0, zx_1.$) `git show-ref --verify --quiet refs/heads/${branchName}`; } else { await (0, zx_1.$) `git show-ref --verify --quiet refs/remotes/${remote}/${branchName}`; } return true; } catch { return false; } } async function detectPackageManager(dir) { if (zx_1.fs.existsSync((0, path_1.join)(dir, 'pnpm-lock.yaml'))) { return 'pnpm'; } if (zx_1.fs.existsSync((0, path_1.join)(dir, 'yarn.lock'))) { return 'yarn'; } if (zx_1.fs.existsSync((0, path_1.join)(dir, 'package-lock.json'))) { return 'npm'; } if (zx_1.fs.existsSync((0, path_1.join)(dir, 'bun.lockb'))) { return 'bun'; } return 'npm'; // default } async function installDependencies(packageManager, workingDir) { (0, zx_1.echo)(zx_1.chalk.blue(`Installing dependencies with ${packageManager}...`)); const originalVerbose = zx_1.$.verbose; const originalCwd = zx_1.$.cwd; zx_1.$.verbose = true; // Show command output try { if (workingDir) { zx_1.$.cwd = workingDir; } await (0, zx_1.$) `pwd`; switch (packageManager) { case 'pnpm': await (0, zx_1.$) `pnpm install --frozen-lockfile`; break; case 'yarn': await (0, zx_1.$) `yarn install --frozen-lockfile`; break; case 'bun': await (0, zx_1.$) `bun install --frozen-lockfile`; break; case 'npm': default: await (0, zx_1.$) `npm ci`; break; } } finally { zx_1.$.verbose = originalVerbose; // Restore original verbose setting zx_1.$.cwd = originalCwd; // Restore original working directory } } async function createWorktree(branchName) { // Configure shell await configureShell(); // Load configuration const config = await loadConfig(); // Check for --no-vscode flag const noVscode = zx_1.argv['no-vscode'] || zx_1.argv['novscode'] || false; if (noVscode) { config.vscode = { ...config.vscode, open: false }; } // Check if we're in a git repository if (!await isGitRepository()) { (0, zx_1.echo)(zx_1.chalk.red('Error: Not in a git repository')); process.exit(1); } let selectedBranch; 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) { (0, zx_1.echo)(zx_1.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; if (config.worktree?.location) { worktreePath = config.worktree.location .replace('{prefix}', prefix) .replace('{branch}', safeBranchName) .replace('{original-branch}', selectedBranch); } else { worktreePath = (0, path_1.join)('..', `${prefix}-${safeBranchName}`); } (0, zx_1.echo)(zx_1.chalk.cyan(`Creating worktree for branch: ${selectedBranch}`)); (0, zx_1.echo)(zx_1.chalk.gray(`Worktree path: ${worktreePath}`)); // Check if worktree directory already exists if (zx_1.fs.existsSync(worktreePath)) { (0, zx_1.echo)(zx_1.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) { (0, zx_1.echo)(zx_1.chalk.blue(`Fetching latest from ${remote}...`)); try { await (0, zx_1.$) `git fetch ${remote}`; } catch (err) { (0, zx_1.echo)(zx_1.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 (0, zx_1.echo)(zx_1.chalk.blue(`Pulling latest changes for ${defaultBranch} branch...`)); try { // Get current branch const currentBranchResult = await (0, zx_1.$) `git rev-parse --abbrev-ref HEAD`; const currentBranch = currentBranchResult.stdout.trim(); if (currentBranch === defaultBranch) { // If we're on the default branch, just pull await (0, zx_1.$) `git pull ${remote} ${defaultBranch}`; } else { // If we're on a different branch, fetch and update the default branch await (0, zx_1.$) `git fetch ${remote} ${defaultBranch}:${defaultBranch}`; } (0, zx_1.echo)(zx_1.chalk.green(`Successfully updated ${defaultBranch} branch`)); } catch (err) { (0, zx_1.echo)(zx_1.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)) { (0, zx_1.echo)(zx_1.chalk.blue(`Creating worktree with existing local branch: ${selectedBranch}`)); await (0, zx_1.$) `git worktree add ${worktreePath} ${selectedBranch}`; } else if (await branchExists(selectedBranch, 'remote', remote)) { (0, zx_1.echo)(zx_1.chalk.blue(`Creating worktree from remote branch: ${selectedBranch}`)); await (0, zx_1.$) `git worktree add ${worktreePath} -b ${selectedBranch} ${remote}/${selectedBranch}`; } else { (0, zx_1.echo)(zx_1.chalk.blue(`Creating worktree with new branch: ${selectedBranch}`)); // Create new branch from remote/defaultBranch to avoid checkout conflicts await (0, zx_1.$) `git worktree add ${worktreePath} -b ${selectedBranch} ${remote}/${defaultBranch}`; } } catch (err) { (0, zx_1.echo)(zx_1.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 = (0, path_1.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 (0, zx_1.echo)(zx_1.chalk.blue(`Setting upstream for ${selectedBranch} to track ${remote}/${selectedBranch}`)); await (0, zx_1.$) `git branch --set-upstream-to=${remote}/${selectedBranch} ${selectedBranch}`; } else { // For new branches, set up push configuration (0, zx_1.echo)(zx_1.chalk.blue(`New branch ${selectedBranch} created locally. Configuring to push to ${remote}/${selectedBranch}`)); await (0, zx_1.$) `git config branch.${selectedBranch}.remote ${remote}`; await (0, zx_1.$) `git config branch.${selectedBranch}.merge refs/heads/${selectedBranch}`; await (0, zx_1.$) `git config push.default simple`; // Auto-push new branches if configured if (config.git?.pushNewBranches) { (0, zx_1.echo)(zx_1.chalk.blue(`Pushing new branch to ${remote}...`)); try { await (0, zx_1.$) `git push -u ${remote} ${selectedBranch}`; (0, zx_1.echo)(zx_1.chalk.green(`Successfully pushed ${selectedBranch} to ${remote}`)); } catch (err) { (0, zx_1.echo)(zx_1.chalk.yellow(`Warning: Could not push new branch: ${err instanceof Error ? err.message : String(err)}`)); } } } } catch (err) { (0, zx_1.echo)(zx_1.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) { (0, zx_1.echo)(zx_1.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 = (0, path_1.join)(process.cwd(), relativePath); const targetDir = (0, path_1.dirname)(targetPath); // Create directory structure if it doesn't exist await (0, zx_1.$) `mkdir -p ${targetDir}`; // Copy the file (we're already in the worktree directory) await (0, zx_1.$) `cp ${envFile} ${targetPath}`; (0, zx_1.echo)(zx_1.chalk.green(`Copied: ${relativePath}`)); } } else { (0, zx_1.echo)(zx_1.chalk.gray('Skipping .env file copying (disabled in config)')); } // Detect package manager and install dependencies if (config.packageManager?.install !== false) { (0, zx_1.echo)(zx_1.chalk.gray(`Current directory: ${process.cwd()}`)); const packageManager = config.packageManager?.force || await detectPackageManager(process.cwd()); if (config.packageManager?.command) { (0, zx_1.echo)(zx_1.chalk.blue(`Running custom install command: ${config.packageManager.command}`)); const originalVerbose = zx_1.$.verbose; zx_1.$.verbose = true; try { await (0, zx_1.$) `${config.packageManager.command.split(' ')}`; } finally { zx_1.$.verbose = originalVerbose; } } else { await installDependencies(packageManager, process.cwd()); } } else { (0, zx_1.echo)(zx_1.chalk.gray('Skipping dependency installation (disabled in config)')); } // Run post-create hooks if (config.hooks?.postCreate && config.hooks.postCreate.length > 0) { (0, zx_1.echo)(zx_1.chalk.blue('Running post-create hooks...')); (0, zx_1.echo)(zx_1.chalk.gray(`Working directory: ${process.cwd()}`)); // Verify we're in the correct worktree const currentBranch = await (0, zx_1.$) `git rev-parse --abbrev-ref HEAD`; (0, zx_1.echo)(zx_1.chalk.gray(`Current branch: ${currentBranch.stdout.trim()}`)); const originalVerbose = zx_1.$.verbose; zx_1.$.verbose = true; // Show hook command output // Prepare template variables for replacement const templateVars = { 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); } (0, zx_1.echo)(zx_1.chalk.gray(`Running: ${expandedHook}`)); (0, zx_1.echo)(zx_1.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 (0, zx_1.$) `cd ${absoluteWorktreePath} && eval ${expandedHook}`; } catch (err) { (0, zx_1.echo)(zx_1.chalk.yellow(`Warning: Hook failed: ${expandedHook}`)); (0, zx_1.echo)(zx_1.chalk.yellow(`Error: ${err instanceof Error ? err.message : String(err)}`)); } } zx_1.$.verbose = originalVerbose; // Restore original verbose setting } // Open in VS Code (after hooks have completed) if (config.vscode?.open !== false) { const absoluteWorktreePath = (0, path_1.resolve)(originalDir, worktreePath); (0, zx_1.echo)(zx_1.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 (0, zx_1.$) `${command}`; } catch (err) { (0, zx_1.echo)(zx_1.chalk.yellow('Failed to open VS Code. You can manually open the project at:'), absoluteWorktreePath); } } (0, zx_1.echo)(zx_1.chalk.green('✅ Worktree created successfully!')); (0, zx_1.echo)(zx_1.chalk.cyan(`📁 Location: ${worktreePath}`)); (0, zx_1.echo)(zx_1.chalk.cyan(`🌿 Branch: ${selectedBranch}`)); } // Main execution const branchName = zx_1.argv._[0]; createWorktree(branchName).catch(err => { (0, zx_1.echo)(zx_1.chalk.red('Error:'), err.message); process.exit(1); });