@adamhancock/worktree
Version: 
Git worktree manager with branch selection, dependency installation, and VS Code integration
437 lines (436 loc) • 18.6 kB
JavaScript
;
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);
});