UNPKG

worktree-tool

Version:

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

200 lines 9.11 kB
import { execFile } from "child_process"; import { Command } from "commander"; import path from "path"; import { promisify } from "util"; import { loadConfig } from "../core/config.js"; import { createGit } from "../core/git.js"; import { AutoRunManager } from "../exec/autorun-manager.js"; import { detectPlatform } from "../platform/detector.js"; import { spawnShell } from "../platform/shell.js"; import { attachToTmuxSession, canAttachToTmux, createTmuxSession, createTmuxWindow, isInsideTmux, isTmuxAvailable, renameTmuxWindow, sanitizeTmuxName, switchToTmuxWindow, tmuxSessionExists } from "../platform/tmux.js"; import { tmuxWindowManager } from "../platform/tmux-window-manager.js"; import { getProjectRoot } from "../utils/find-root.js"; import { getLogger } from "../utils/logger.js"; const execFileAsync = promisify(execFile); import { GIT_ERRORS } from "../core/constants.js"; import { getErrorMessage, handleCommandError } from "../utils/error-handler.js"; import { ConfigError, GitError } from "../utils/errors.js"; import { sanitizeWorktreeName as sanitizeWorktreeNameUtil } from "../utils/sanitize.js"; import { validateWorktreeName } from "../utils/validation.js"; /** * Sanitize worktree name for git branch compatibility * Git branch names cannot contain certain characters */ export const sanitizeWorktreeName = sanitizeWorktreeNameUtil; /** * Validate create command options */ export function validateCreateOptions(options) { validateWorktreeName(options.name); } /** * Execute the create command */ export async function executeCreate(options) { const logger = getLogger(); try { // Validate options first validateCreateOptions(options); logger.verbose("Loading configuration..."); // Load configuration const config = await loadConfig(); if (!config) { throw new ConfigError("Repository not initialized. Run \"wtt init\" first"); } logger.verbose("Checking git repository..."); // Get the project root directory const projectRoot = await getProjectRoot(); // Check if we're in a git repository const git = createGit(projectRoot); const isRepo = await git.isGitRepository(); if (!isRepo) { throw new GitError("Not in a git repository"); } // Check if the repository has any commits const hasCommits = await git.hasCommits(); if (!hasCommits) { throw new GitError(GIT_ERRORS.NO_COMMITS); } // Sanitize the worktree name const sanitizedName = sanitizeWorktreeName(options.name); const worktreePath = path.join(projectRoot, config.baseDir, sanitizedName); logger.verbose(`Creating worktree: ${sanitizedName}`); logger.verbose(`Worktree path: ${worktreePath}`); // Get absolute path for tmux const absoluteWorktreePath = path.resolve(worktreePath); // Create the worktree based on main branch // Since git is created with projectRoot as baseDir, we need to pass a relative path const relativeWorktreePath = path.join(config.baseDir, sanitizedName); try { await git.createWorktree(relativeWorktreePath, sanitizedName); } catch (error) { // Check if the error is about HEAD not being valid (in case our check missed it) const errorMessage = getErrorMessage(error); if (errorMessage.includes(GIT_ERRORS.INVALID_HEAD) && errorMessage.includes("HEAD")) { throw new GitError(GIT_ERRORS.NO_COMMITS); } throw new GitError(`Failed to create worktree: ${errorMessage}`); } logger.verbose("Worktree created successfully"); // Show concise success message before launching shell/tmux logger.success(`Created worktree: ${sanitizedName}`); // Run autoRun commands const autoRunManager = new AutoRunManager(config, logger); const newWorktree = { path: absoluteWorktreePath, branch: sanitizedName, commit: "", // Not needed for autoRun isMain: false, isLocked: false, }; await autoRunManager.runAutoCommands(newWorktree); // Sort windows if autoSort is enabled and tmux is being used if (config.autoSort && config.tmux && await isTmuxAvailable()) { try { const sessionName = sanitizeTmuxName(config.projectName); await tmuxWindowManager.sortWindowsAlphabetically(sessionName); } catch (error) { logger.warn(`Failed to sort windows: ${getErrorMessage(error)}`); } } // Handle tmux or shell spawning if (config.tmux && await isTmuxAvailable()) { await handleTmuxIntegration(config.projectName, sanitizedName, absoluteWorktreePath, logger); } else { logger.info(`Opening shell in ${path.relative(process.cwd(), absoluteWorktreePath)}`); await handleShellSpawning(sanitizedName, absoluteWorktreePath, logger); } } catch (error) { handleCommandError(error, logger); } } /** * Handle tmux integration for the new worktree */ async function handleTmuxIntegration(projectName, worktreeName, worktreePath, logger) { try { const sessionName = sanitizeTmuxName(projectName); const windowName = sanitizeTmuxName(worktreeName); logger.verbose(`Setting up tmux session: ${sessionName}`); // Check if session exists const sessionExists = await tmuxSessionExists(sessionName); const insideTmux = isInsideTmux(); if (!sessionExists) { // Create new session with first window in the worktree directory logger.verbose("Creating tmux session with first window..."); await createTmuxSession(sessionName, worktreePath); // Rename the first window to match the worktree await renameTmuxWindow(sessionName, 0, windowName); // Handle session attachment based on environment if (insideTmux) { // If we're inside tmux, switch to the session instead logger.verbose("Switching to tmux session..."); await execFileAsync("tmux", ["switch-client", "-t", sessionName]); } else if (canAttachToTmux()) { // Only attach if we're in a proper terminal logger.verbose("Attaching to tmux session..."); await attachToTmuxSession(sessionName, windowName); } else { // Can't attach, just inform the user logger.info(`Created tmux session '${sessionName}' with window '${windowName}'`); logger.info(`Run 'tmux attach -t ${sessionName}' to enter the session`); } } else { // Session exists, create a new window logger.verbose(`Creating tmux window: ${windowName}`); await createTmuxWindow(sessionName, windowName, worktreePath); // Handle window switching based on environment if (insideTmux) { logger.verbose("Switching to tmux window..."); await switchToTmuxWindow(sessionName, windowName); } else if (canAttachToTmux()) { // Attach to session and switch to the new window logger.verbose("Attaching to tmux session and switching to new window..."); await attachToTmuxSession(sessionName, windowName); } else { // Can't attach, just inform the user logger.info(`Created tmux window '${windowName}' in session '${sessionName}'`); logger.info(`Run 'tmux attach -t ${sessionName}' to enter the session`); } } } catch (error) { logger.warn(`Tmux integration failed: ${getErrorMessage(error)}`); logger.verbose("Falling back to shell spawning..."); await handleShellSpawning(worktreeName, worktreePath, logger); } } /** * Handle shell spawning for the new worktree */ async function handleShellSpawning(worktreeName, worktreePath, logger) { try { const platform = detectPlatform(); logger.verbose(`Spawning ${platform.shellType} shell in ${worktreePath}`); // Spawn shell with custom prompt await spawnShell(worktreePath, platform.shellType, worktreeName); } catch (error) { logger.warn(`Failed to spawn shell: ${getErrorMessage(error)}`); logger.info(`Worktree created at: ${worktreePath}`); logger.info(`You can manually navigate there with: cd ${worktreePath}`); } } /** * Create the create command */ export const createCommand = new Command("create") .description("Create a new worktree for a feature branch") .argument("<name>", "name of the worktree and branch to create") .action((name) => executeCreate({ name })); //# sourceMappingURL=create.js.map