UNPKG

worktree-tool

Version:

A command-line tool for managing Git worktrees with integrated tmux/shell session management

198 lines 7.97 kB
import { Command } from "commander"; import path from "path"; import { changeToMainWorktree, isCurrentProcessInWorktree, terminateShellProcessesInDirectory, } from "../platform/process-cleanup.js"; import { closeTmuxWindowsForWorktree } from "../platform/tmux-cleanup.js"; import { validateWorktreeName } from "../utils/validation.js"; import { BaseCommand } from "./base.js"; export class RemoveCommand extends BaseCommand { requiresConfig() { return true; } requiresGitRepo() { return true; } validateOptions(options) { // Check for conflicting options if (options.prune && options.worktrees.length > 0) { throw new Error("Cannot specify worktrees with --prune option"); } // Check that we have something to do if (!options.prune && options.worktrees.length === 0) { throw new Error("No worktrees specified. Use --prune or specify worktree names"); } // Validate worktree names for (const name of options.worktrees) { validateWorktreeName(name); } } async executeCommand(options, context) { if (options.prune) { await this.executePrune(context); return; } // Process each worktree for (const worktreeName of options.worktrees) { await this.removeWorktree(worktreeName, options.force ?? false, context); } } async removeWorktree(worktreeName, force, context) { const { logger, git } = context; // Find the worktree const worktree = await git.getWorktreeByName(worktreeName); if (!worktree) { logger.error(`Worktree '${worktreeName}' not found`); return; } // Check if it's the main worktree if (worktree.isMain) { logger.error("Cannot remove main worktree"); return; } // Perform safety checks unless forced if (!force) { const errors = await this.performSafetyChecks(worktree, context); if (errors.length > 0) { for (const error of errors) { logger.error(error); } return; } } // Close tmux windows and terminate shell processes await this.performCleanup(worktree, worktreeName, context); // Remove the worktree try { await git.removeWorktree(worktree.path, force); logger.info(`Removed worktree '${worktreeName}'`); } catch (error) { logger.error(`Failed to remove worktree '${worktreeName}': ${String(error)}`); } } async performSafetyChecks(worktree, context) { const { git } = context; const errors = []; // Check for untracked files if (await git.hasUntrackedFiles(worktree.path)) { errors.push("Has untracked files"); } // Check for uncommitted changes if (await git.hasUncommittedChanges(worktree.path)) { errors.push("Has uncommitted changes"); } // Check for staged changes if (await git.hasStagedChanges(worktree.path)) { errors.push("Has staged changes"); } // Check for unmerged commits const mainBranch = await git.getMainBranch(); const branchName = worktree.branch.replace(/^refs\/heads\//, ""); if (await git.hasUnmergedCommits(branchName, mainBranch)) { errors.push("Has unmerged commits"); } // Check for stashed changes if (await git.hasStashedChanges(branchName)) { errors.push("Has stashed changes"); } // Check for submodule modifications if (await git.hasSubmoduleModifications(worktree.path)) { errors.push("Has submodule modifications"); } return errors; } async performCleanup(worktree, worktreeName, context) { const { logger, git, config } = context; // Check if we're removing the current directory if (isCurrentProcessInWorktree(worktree.path)) { const mainWorktree = await git.getMainWorktree(); changeToMainWorktree(mainWorktree.path); logger.verbose("Changed to main worktree before removal"); } // Close tmux windows if (config?.tmux && config.projectName) { await closeTmuxWindowsForWorktree(config.projectName, worktreeName); logger.verbose("Closed tmux windows"); } // Terminate shell processes await terminateShellProcessesInDirectory(worktree.path); logger.verbose("Terminated shell processes"); } async executePrune(context) { const { logger, git } = context; logger.verbose("Finding fully merged worktrees..."); const worktrees = await git.listWorktrees(); const mainBranch = await git.getMainBranch(); const mainWorktree = worktrees.find((w) => w.isMain); if (!mainWorktree) { throw new Error("Could not find main worktree"); } const pruneCandidates = []; // Check each non-main worktree for (const worktree of worktrees) { if (worktree.isMain || worktree.isLocked) { continue; } // Check if fully merged const branchName = worktree.branch.replace(/^refs\/heads\//, ""); const hasUnmerged = await git.hasUnmergedCommits(branchName, mainBranch); if (!hasUnmerged) { pruneCandidates.push(worktree); } } if (pruneCandidates.length === 0) { logger.info("No fully merged worktrees to prune"); return; } logger.verbose(`Found ${String(pruneCandidates.length)} worktrees to prune`); // Remove each candidate const removed = []; for (const worktree of pruneCandidates) { const errors = await this.performSafetyChecks(worktree, context); if (errors.length === 0) { // Extract worktree name for display const worktreeName = path.basename(worktree.path); // Run cleanup and removal await this.performCleanup(worktree, worktreeName, context); try { await git.removeWorktree(worktree.path, false); removed.push(worktreeName); logger.verbose(`Pruned worktree '${worktreeName}'`); } catch (error) { logger.verbose(`Failed to prune '${String(worktreeName)}': ${String(error)}`); } } else { const worktreeName = path.basename(worktree.path); logger.verbose(`Skipping ${worktreeName}: ${errors.join(", ")}`); } } // Report results if (removed.length === 0) { logger.info("No worktrees pruned (all had pending changes)"); } else if (removed.length === 1) { logger.success(`Pruned worktree: ${removed[0] ?? ""}`); } else { logger.success(`Pruned ${String(removed.length)} worktrees: ${removed.join(", ")}`); } } } export const removeCommand = new Command("remove") .description("Remove git worktrees with safety checks") .argument("[worktrees...]", "names of worktrees to remove") .option("-f, --force", "force removal, bypassing all safety checks") .option("--prune", "remove all fully merged worktrees") .action(async (worktrees, options) => { const removeOptions = { worktrees, force: options.force, prune: options.prune, verbose: options.verbose, quiet: options.quiet, }; const command = new RemoveCommand(); await command.execute(removeOptions); }); //# sourceMappingURL=remove.js.map