worktree-tool
Version:
A command-line tool for managing Git worktrees with integrated tmux/shell session management
200 lines • 9.11 kB
JavaScript
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