|
| 1 | +import { execa } from "execa"; |
| 2 | +import chalk from "chalk"; |
| 3 | +import { stat } from "node:fs/promises"; |
| 4 | +import { resolve, join, dirname, basename } from "node:path"; |
| 5 | +import { getDefaultEditor } from "../config.js"; |
| 6 | +import { isWorktreeClean, isMainRepoBare } from "../utils/git.js"; |
| 7 | +export async function extractWorktreeHandler(branchName, options = {}) { |
| 8 | + try { |
| 9 | + // 1. Validate we're in a git repo |
| 10 | + await execa("git", ["rev-parse", "--is-inside-work-tree"]); |
| 11 | + console.log(chalk.blue("Checking if main worktree is clean...")); |
| 12 | + const isClean = await isWorktreeClean("."); |
| 13 | + if (!isClean) { |
| 14 | + console.error(chalk.red("❌ Error: Your main worktree is not clean.")); |
| 15 | + console.error(chalk.yellow("Creating a new worktree requires a clean main worktree state.")); |
| 16 | + console.error(chalk.cyan("Please commit, stash, or discard your changes. Run 'git status' to see the changes.")); |
| 17 | + process.exit(1); |
| 18 | + } |
| 19 | + else { |
| 20 | + console.log(chalk.green("✅ Main worktree is clean.")); |
| 21 | + } |
| 22 | + // 2. Determine which branch to extract |
| 23 | + let selectedBranch = branchName; |
| 24 | + if (!selectedBranch) { |
| 25 | + // Get current branch if no branch specified |
| 26 | + const { stdout: currentBranch } = await execa("git", ["branch", "--show-current"]); |
| 27 | + selectedBranch = currentBranch.trim(); |
| 28 | + if (!selectedBranch) { |
| 29 | + console.error(chalk.red("❌ Error: Could not determine current branch (possibly in detached HEAD state).")); |
| 30 | + console.error(chalk.yellow("Please specify a branch name: wt extract <branch-name>")); |
| 31 | + process.exit(1); |
| 32 | + } |
| 33 | + console.log(chalk.blue(`No branch specified. Using current branch: ${selectedBranch}`)); |
| 34 | + } |
| 35 | + // 3. Get existing worktrees to check if branch already has one |
| 36 | + const { stdout: worktrees } = await execa("git", ["worktree", "list", "--porcelain"]); |
| 37 | + const worktreeBranches = worktrees |
| 38 | + .split('\n') |
| 39 | + .filter(line => line.startsWith('branch refs/heads/')) |
| 40 | + .map(line => line.replace('branch refs/heads/', '')); |
| 41 | + if (worktreeBranches.includes(selectedBranch)) { |
| 42 | + console.error(chalk.red(`❌ Error: Branch "${selectedBranch}" already has a worktree.`)); |
| 43 | + console.error(chalk.yellow("Use 'wt list' to see existing worktrees.")); |
| 44 | + process.exit(1); |
| 45 | + } |
| 46 | + // 4. Verify the branch exists (either locally or remotely) |
| 47 | + const { stdout: localBranches } = await execa("git", ["branch", "--format=%(refname:short)"]); |
| 48 | + const { stdout: remoteBranches } = await execa("git", ["branch", "-r", "--format=%(refname:short)"]); |
| 49 | + const localBranchList = localBranches.split('\n').filter(b => b.trim() !== ''); |
| 50 | + const remoteBranchList = remoteBranches |
| 51 | + .split('\n') |
| 52 | + .filter(b => b.trim() !== '' && b.startsWith('origin/')) |
| 53 | + .map(b => b.replace('origin/', '')); |
| 54 | + const branchExistsLocally = localBranchList.includes(selectedBranch); |
| 55 | + const branchExistsRemotely = remoteBranchList.includes(selectedBranch); |
| 56 | + if (!branchExistsLocally && !branchExistsRemotely) { |
| 57 | + console.error(chalk.red(`❌ Error: Branch "${selectedBranch}" does not exist locally or remotely.`)); |
| 58 | + process.exit(1); |
| 59 | + } |
| 60 | + // 5. Build final path for the new worktree |
| 61 | + let folderName; |
| 62 | + if (options.path) { |
| 63 | + folderName = options.path; |
| 64 | + } |
| 65 | + else { |
| 66 | + // Derive the short name for the directory from the branch name |
| 67 | + const shortBranchName = selectedBranch.split('/').filter(part => part.length > 0).pop() || selectedBranch; |
| 68 | + const currentDir = process.cwd(); |
| 69 | + const parentDir = dirname(currentDir); |
| 70 | + const currentDirName = basename(currentDir); |
| 71 | + // Create a sibling directory using the short branch name |
| 72 | + folderName = join(parentDir, `${currentDirName}-${shortBranchName}`); |
| 73 | + } |
| 74 | + const resolvedPath = resolve(folderName); |
| 75 | + // Check if directory already exists |
| 76 | + try { |
| 77 | + await stat(resolvedPath); |
| 78 | + console.error(chalk.red(`❌ Error: Directory already exists at: ${resolvedPath}`)); |
| 79 | + console.error(chalk.yellow("Please choose a different path with --path option.")); |
| 80 | + process.exit(1); |
| 81 | + } |
| 82 | + catch (error) { |
| 83 | + // Directory doesn't exist, continue with creation |
| 84 | + } |
| 85 | + // 6. Check if this is a bare repository |
| 86 | + if (await isMainRepoBare()) { |
| 87 | + console.error(chalk.red("❌ Error: The main repository is configured as 'bare' (core.bare=true).")); |
| 88 | + console.error(chalk.red(" This prevents normal Git operations. Please fix the configuration:")); |
| 89 | + console.error(chalk.cyan(" git config core.bare false")); |
| 90 | + process.exit(1); |
| 91 | + } |
| 92 | + // 7. Create the worktree |
| 93 | + console.log(chalk.blue(`Extracting branch "${selectedBranch}" to worktree at: ${resolvedPath}`)); |
| 94 | + // Check if we need to fetch the branch first (if it's only remote) |
| 95 | + if (!branchExistsLocally && branchExistsRemotely) { |
| 96 | + console.log(chalk.yellow(`Branch "${selectedBranch}" is remote-only. Creating local tracking branch...`)); |
| 97 | + // Create worktree with remote branch |
| 98 | + await execa("git", ["worktree", "add", "--track", "-b", selectedBranch, resolvedPath, `origin/${selectedBranch}`]); |
| 99 | + } |
| 100 | + else { |
| 101 | + // Branch exists locally |
| 102 | + await execa("git", ["worktree", "add", resolvedPath, selectedBranch]); |
| 103 | + } |
| 104 | + console.log(chalk.green(`✅ Successfully extracted branch "${selectedBranch}" to worktree.`)); |
| 105 | + // 8. (Optional) Install dependencies if --install flag is provided |
| 106 | + if (options.install) { |
| 107 | + console.log(chalk.blue(`Installing dependencies using ${options.install} in ${resolvedPath}...`)); |
| 108 | + await execa(options.install, ["install"], { cwd: resolvedPath, stdio: "inherit" }); |
| 109 | + } |
| 110 | + // 9. Open in the specified editor (or use configured default) |
| 111 | + const configuredEditor = getDefaultEditor(); |
| 112 | + const editorCommand = options.editor || configuredEditor; |
| 113 | + console.log(chalk.blue(`Opening ${resolvedPath} in ${editorCommand}...`)); |
| 114 | + try { |
| 115 | + await execa(editorCommand, [resolvedPath], { stdio: "inherit" }); |
| 116 | + } |
| 117 | + catch (editorError) { |
| 118 | + console.error(chalk.red(`Failed to open editor "${editorCommand}". Please ensure it's installed and in your PATH.`)); |
| 119 | + console.warn(chalk.yellow(`Continuing without opening editor.`)); |
| 120 | + } |
| 121 | + console.log(chalk.green(`\n✅ Worktree extracted at ${resolvedPath}.`)); |
| 122 | + if (options.install) |
| 123 | + console.log(chalk.green(`✅ Dependencies installed using ${options.install}.`)); |
| 124 | + console.log(chalk.green(`✅ Attempted to open in ${editorCommand}.`)); |
| 125 | + } |
| 126 | + catch (error) { |
| 127 | + if (error instanceof Error) { |
| 128 | + console.error(chalk.red("Failed to extract worktree:"), error.message); |
| 129 | + } |
| 130 | + else { |
| 131 | + console.error(chalk.red("Failed to extract worktree:"), error); |
| 132 | + } |
| 133 | + process.exit(1); |
| 134 | + } |
| 135 | +} |
0 commit comments