Created
December 3, 2025 23:12
-
-
Save gregberns/abdfddd566eb65cef0e49fb7877027b0 to your computer and use it in GitHub Desktop.
Git Worktree Creator Script - Useful for running multiple coding agents locally
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Git Worktree Creator Script | |
| * | |
| * This script creates new git worktrees for development, allowing you to work on multiple | |
| * branches simultaneously without switching branches in your main repository. | |
| * | |
| * Key Features: | |
| * - Creates worktrees from any base branch (not just current branch) | |
| * - Automatically handles uncommitted work in current directory | |
| * - Copies useful files (.env, .vscode, etc.) to new worktree | |
| * - Installs dependencies automatically | |
| * - Supports custom branch names and base branches | |
| * | |
| * Usage Examples: | |
| * wtadd worktree-dir # Create worktree from current/main branch | |
| * wtadd worktree-dir --base main # Explicitly use main as base branch | |
| * wtadd worktree-dir --base develop # Use develop as base branch | |
| * wtadd worktree-dir --branch feature/GOLD-1234 # Custom branch name | |
| * wtadd worktree-dir --branch feature/GOLD-1234 --base main # Custom branch from main base | |
| * wtadd worktree-dir --setup # Run setup.sh script after creation | |
| * wtadd worktree-dir --exec "python -m venv venv" # Execute custom command in worktree | |
| * wtadd worktree-dir --setup --exec "source venv/bin/activate && pip install -r requirements.txt" | |
| * | |
| * Installation: | |
| * cp ./worktree.js $HOME/bin/wtadd | |
| * chmod +x $HOME/bin/wtadd | |
| */ | |
| import { execSync } from "child_process"; | |
| import { existsSync, copyFileSync, mkdirSync, readdirSync, statSync } from "fs"; | |
| import { join, dirname, basename } from "path"; | |
| // Parse command line arguments | |
| function parseArgs() { | |
| const args = process.argv.slice(2); | |
| let subPath = null; | |
| let branchName = null; | |
| let baseBranch = null; | |
| let execCommand = null; | |
| let runSetup = false; | |
| for (let i = 0; i < args.length; i++) { | |
| if (args[i] === "--branch" && i + 1 < args.length) { | |
| branchName = args[i + 1]; | |
| i++; // Skip the next argument since we consumed it | |
| } else if (args[i] === "--base" && i + 1 < args.length) { | |
| baseBranch = args[i + 1]; | |
| i++; // Skip the next argument since we consumed it | |
| } else if (args[i] === "--exec" && i + 1 < args.length) { | |
| execCommand = args[i + 1]; | |
| i++; // Skip the next argument since we consumed it | |
| } else if (args[i] === "--setup") { | |
| runSetup = true; | |
| } else if (!args[i].startsWith("--")) { | |
| // First non-flag argument is the sub-path | |
| if (!subPath) { | |
| subPath = args[i]; | |
| } | |
| } | |
| } | |
| if (!subPath) { | |
| console.error( | |
| 'Error: Please provide a sub-path argument (e.g., "INI-1234")', | |
| ); | |
| console.error( | |
| "Usage: wtadd [--branch <branch-name>] [--base <base-branch>] [--exec <command>] [--setup] <sub-path>", | |
| ); | |
| process.exit(1); | |
| } | |
| // Default branch name to sub-path if not provided | |
| if (!branchName) { | |
| branchName = subPath; | |
| } | |
| return { subPath, branchName, baseBranch, execCommand, runSetup }; | |
| } | |
| const { | |
| subPath, | |
| branchName, | |
| baseBranch: userSpecifiedBaseBranch, | |
| execCommand, | |
| runSetup, | |
| } = parseArgs(); | |
| // Allowed branch names | |
| const ALLOWED_BRANCHES = ["main", "master", "dev", "development"]; | |
| // Get git repository root from current working directory | |
| function getGitRoot() { | |
| try { | |
| const root = execSync("git rev-parse --show-toplevel", { | |
| encoding: "utf-8", | |
| cwd: process.cwd(), | |
| }).trim(); | |
| return root; | |
| } catch (error) { | |
| console.error("Error: Not in a git repository or git command failed"); | |
| process.exit(1); | |
| } | |
| } | |
| // Get current branch | |
| function getCurrentBranch() { | |
| const gitRoot = getGitRoot(); | |
| try { | |
| const branch = execSync("git rev-parse --abbrev-ref HEAD", { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| }).trim(); | |
| return branch; | |
| } catch (error) { | |
| console.error("Error: Could not determine current branch"); | |
| process.exit(1); | |
| } | |
| } | |
| // Check if branch exists | |
| function branchExists(branchName) { | |
| const gitRoot = getGitRoot(); | |
| try { | |
| execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| stdio: "ignore", | |
| }); | |
| return true; | |
| } catch (error) { | |
| return false; | |
| } | |
| } | |
| // Check if branch is allowed | |
| function isBranchAllowed(branch) { | |
| return ALLOWED_BRANCHES.includes(branch); | |
| } | |
| // Get current directory name | |
| function getCurrentDirName() { | |
| const gitRoot = getGitRoot(); | |
| return basename(gitRoot); | |
| } | |
| // Get parent directory | |
| function getParentDir() { | |
| const gitRoot = getGitRoot(); | |
| return dirname(gitRoot); | |
| } | |
| // Copy file or directory recursively | |
| function copyRecursive(src, dest) { | |
| const stat = statSync(src); | |
| if (stat.isDirectory()) { | |
| if (!existsSync(dest)) { | |
| mkdirSync(dest, { recursive: true }); | |
| } | |
| const entries = readdirSync(src); | |
| for (const entry of entries) { | |
| const srcPath = join(src, entry); | |
| const destPath = join(dest, entry); | |
| copyRecursive(srcPath, destPath); | |
| } | |
| } else { | |
| copyFileSync(src, dest); | |
| } | |
| } | |
| // Check if file exists | |
| function fileExists(filePath) { | |
| const gitRoot = getGitRoot(); | |
| return existsSync(join(gitRoot, filePath)); | |
| } | |
| // Execute command in worktree directory | |
| function execInWorktree(worktreePath, command) { | |
| try { | |
| execSync(command, { | |
| encoding: "utf-8", | |
| cwd: worktreePath, | |
| stdio: "inherit", | |
| }); | |
| } catch (error) { | |
| console.error(`Error executing command: ${command}`); | |
| throw error; | |
| } | |
| } | |
| // Main execution | |
| async function main() { | |
| // Check current branch | |
| const currentBranch = getCurrentBranch(); | |
| console.log(`Current branch: ${currentBranch}`); | |
| console.log(`Worktree branch: ${branchName}`); | |
| // Determine the base branch for the worktree | |
| let baseBranch; | |
| if (userSpecifiedBaseBranch) { | |
| // User explicitly specified a base branch | |
| baseBranch = userSpecifiedBaseBranch; | |
| console.log(`Using user-specified base branch: ${baseBranch}`); | |
| // Verify the specified base branch exists | |
| const gitRoot = getGitRoot(); | |
| try { | |
| execSync(`git show-ref --verify --quiet refs/heads/${baseBranch}`, { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| stdio: "ignore", | |
| }); | |
| } catch (error) { | |
| console.error( | |
| `Error: Specified base branch "${baseBranch}" does not exist locally.`, | |
| ); | |
| process.exit(1); | |
| } | |
| } else { | |
| // No user-specified base branch, use automatic logic | |
| baseBranch = currentBranch; | |
| // If current branch is not allowed, try to use main instead | |
| if (!isBranchAllowed(currentBranch)) { | |
| console.log(`Current branch "${currentBranch}" is not in allowed list.`); | |
| console.log(`Checking if 'main' branch exists and is up to date...`); | |
| const gitRoot = getGitRoot(); | |
| try { | |
| // Check if main branch exists | |
| execSync(`git show-ref --verify --quiet refs/heads/main`, { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| stdio: "ignore", | |
| }); | |
| // Check if main is up to date with remote | |
| execSync(`git fetch origin main`, { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| stdio: "ignore", | |
| }); | |
| const localMain = execSync(`git rev-parse main`, { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| }).trim(); | |
| const remoteMain = execSync(`git rev-parse origin/main`, { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| }).trim(); | |
| if (localMain !== remoteMain) { | |
| console.log( | |
| `Warning: Local main is not up to date with remote. Using local main anyway.`, | |
| ); | |
| } | |
| baseBranch = "main"; | |
| console.log(`Using 'main' as base branch for worktree creation.`); | |
| } catch (error) { | |
| console.error( | |
| `Error: Current branch "${currentBranch}" is not allowed and 'main' branch is not available or up to date. ` + | |
| `Must be on one of: ${ALLOWED_BRANCHES.join(", ")}`, | |
| ); | |
| process.exit(1); | |
| } | |
| } | |
| } | |
| // Determine worktree path | |
| const currentDirName = getCurrentDirName(); | |
| const parentDir = getParentDir(); | |
| const worktreeName = `${currentDirName}-${subPath}`; | |
| const worktreePath = join(parentDir, worktreeName); | |
| console.log(`Creating worktree: ${worktreePath}`); | |
| // Check if worktree already exists | |
| if (existsSync(worktreePath)) { | |
| console.error(`Error: Directory already exists: ${worktreePath}`); | |
| process.exit(1); | |
| } | |
| // Create worktree | |
| const gitRoot = getGitRoot(); | |
| const branchAlreadyExists = branchExists(branchName); | |
| try { | |
| let worktreeCommand; | |
| if (branchAlreadyExists) { | |
| // Branch exists, checkout the existing branch | |
| console.log(`Using existing branch: ${branchName}`); | |
| worktreeCommand = `git worktree add "${worktreePath}" ${branchName}`; | |
| } else { | |
| // Branch doesn't exist, create new branch from base branch | |
| console.log(`Creating new branch: ${branchName} from ${baseBranch}`); | |
| worktreeCommand = `git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`; | |
| } | |
| execSync(worktreeCommand, { | |
| encoding: "utf-8", | |
| cwd: gitRoot, | |
| stdio: "inherit", | |
| }); | |
| console.log("Worktree created successfully"); | |
| } catch (error) { | |
| console.error("Error creating worktree"); | |
| process.exit(1); | |
| } | |
| // Files and directories to copy | |
| const filesToCopy = ["AGENTS.md", "AGENT.md", ".env", ".vscode", ".cursor"]; | |
| // Copy files/directories | |
| console.log("\nCopying files and directories..."); | |
| for (const item of filesToCopy) { | |
| const srcPath = join(gitRoot, item); | |
| const destPath = join(worktreePath, item); | |
| if (existsSync(srcPath)) { | |
| console.log(` Copying ${item}...`); | |
| copyRecursive(srcPath, destPath); | |
| } else { | |
| console.log(` Skipping ${item} (not found)`); | |
| } | |
| } | |
| // Check for dependency files and install | |
| console.log("\nChecking for dependencies..."); | |
| const hasRequirementsTxt = fileExists("requirements.txt"); | |
| const hasPackageJson = fileExists("package.json"); | |
| const hasYarnLock = fileExists("yarn.lock"); | |
| const hasPipfile = fileExists("Pipfile"); | |
| const hasPoetryLock = fileExists("poetry.lock"); | |
| if (hasRequirementsTxt || hasPipfile || hasPoetryLock) { | |
| console.log(" Python dependencies detected"); | |
| if (hasRequirementsTxt) { | |
| console.log(" Installing Python dependencies via pip..."); | |
| execInWorktree(worktreePath, "pip install -r requirements.txt"); | |
| } else if (hasPipfile) { | |
| console.log(" Installing Python dependencies via pipenv..."); | |
| execInWorktree(worktreePath, "pipenv install"); | |
| } else if (hasPoetryLock) { | |
| console.log(" Installing Python dependencies via poetry..."); | |
| execInWorktree(worktreePath, "poetry install"); | |
| } | |
| } | |
| if (hasPackageJson) { | |
| console.log(" Node.js dependencies detected"); | |
| if (hasYarnLock) { | |
| console.log(" Installing Node.js dependencies via yarn..."); | |
| execInWorktree(worktreePath, "yarn install"); | |
| } else { | |
| console.log(" Installing Node.js dependencies via npm..."); | |
| execInWorktree(worktreePath, "npm install"); | |
| } | |
| } | |
| if (!hasRequirementsTxt && !hasPackageJson && !hasPipfile && !hasPoetryLock) { | |
| console.log(" No dependency files found"); | |
| } | |
| // Execute custom commands if specified | |
| if (runSetup) { | |
| console.log("\nRunning setup script..."); | |
| const setupScriptPath = join(gitRoot, "setup.sh"); | |
| if (existsSync(setupScriptPath)) { | |
| console.log(" Executing setup.sh..."); | |
| try { | |
| execInWorktree(worktreePath, `bash "${setupScriptPath}"`); | |
| console.log(" Setup script completed successfully"); | |
| } catch (error) { | |
| console.error(" Error executing setup script"); | |
| process.exit(1); | |
| } | |
| } else { | |
| console.error(" Error: setup.sh not found in repository root"); | |
| process.exit(1); | |
| } | |
| } | |
| if (execCommand) { | |
| console.log(`\nExecuting custom command: ${execCommand}`); | |
| try { | |
| execInWorktree(worktreePath, execCommand); | |
| console.log(" Custom command completed successfully"); | |
| } catch (error) { | |
| console.error(" Error executing custom command"); | |
| process.exit(1); | |
| } | |
| } | |
| console.log(`\n✅ Worktree setup complete: ${worktreePath}`); | |
| } | |
| main().catch((error) => { | |
| console.error("Unexpected error:", error); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment