worktree-tool
Version:
A command-line tool for managing Git worktrees with integrated tmux/shell session management
215 lines • 9.38 kB
JavaScript
import { Command } from "commander";
import path from "path";
import readline from "readline/promises";
import { createGit } from "../core/git.js";
import { getErrorMessage } from "../utils/error-handler.js";
import { GitError } from "../utils/errors.js";
import { validateWorktreeName } from "../utils/validation.js";
import { BaseCommand } from "./base.js";
export class MergeCommand extends BaseCommand {
requiresConfig() {
return true;
}
requiresGitRepo() {
return true;
}
validateOptions(options) {
// Validate worktree name if provided
if (options.worktree) {
validateWorktreeName(options.worktree);
}
}
async executeCommand(options, context) {
const { logger, git } = context;
// Step 1: Determine target worktree
const targetWorktree = await this.getTargetWorktree(options, context);
logger.verbose(`Target worktree: ${targetWorktree.name}`);
// Step 2: Validate clean working tree
if (!options.force) {
// If we specified a worktree, check that worktree for changes
// Otherwise check the current directory
const checkPath = options.worktree ? targetWorktree.info.path : process.cwd();
const localGit = createGit(checkPath);
const hasChanges = await localGit.hasUncommittedChanges();
if (hasChanges) {
throw new GitError("Working tree has uncommitted changes. Use --force to override.");
}
}
// Step 3: Fetch latest changes
if (!options.noFetch) {
logger.info("Fetching latest changes...");
await git.fetch();
}
// Step 4: Perform merge
if (options.update) {
// Merge main into worktree
await this.mergeMainIntoWorktree(targetWorktree.name, context);
}
else {
// Merge worktree into main
await this.mergeWorktreeIntoMain(targetWorktree.name, context);
}
}
async getTargetWorktree(options, context) {
const { git } = context;
if (options.worktree) {
// User specified a worktree
const info = await git.getWorktreeByName(options.worktree);
if (!info) {
throw new Error(`Worktree '${options.worktree}' not found`);
}
return { name: options.worktree, info };
}
// Get current worktree
const currentPath = path.resolve(process.cwd());
// Create a git instance from current directory to get accurate worktree info
const localGit = createGit(currentPath);
try {
// Check if we're in a git worktree by getting the current branch
const currentBranch = await localGit.getCurrentBranch();
// Get worktree list from the main git instance
const worktrees = await git.listWorktrees();
// Find worktree by branch name
const currentWorktree = worktrees.find((wt) => {
const branchName = wt.branch ? wt.branch.replace(/^refs\/heads\//, "") : "";
return branchName === currentBranch;
});
if (currentWorktree) {
if (currentWorktree.isMain) {
throw new Error("Not in a worktree. Run from within a worktree or specify worktree name.");
}
return { name: currentBranch, info: currentWorktree };
}
// Fallback: try path-based detection
const repoRoot = await git.getRepoRoot();
for (const wt of worktrees) {
const worktreePath = path.isAbsolute(wt.path) ?
path.resolve(wt.path) :
path.resolve(repoRoot, wt.path);
if (currentPath === worktreePath || currentPath.startsWith(worktreePath + path.sep)) {
if (wt.isMain) {
throw new Error("Not in a worktree. Run from within a worktree or specify worktree name.");
}
const name = wt.branch ? wt.branch.replace(/^refs\/heads\//, "") : path.basename(wt.path);
return { name, info: wt };
}
}
}
catch {
// Failed to detect worktree by branch, will try path-based detection
}
throw new Error("Not in a worktree. Run from within a worktree or specify worktree name.");
}
async mergeWorktreeIntoMain(worktreeName, context) {
const { logger, git, config } = context;
const mainBranch = config?.mainBranch ?? "main";
const currentBranch = await git.getCurrentBranch();
logger.info(`Merging ${worktreeName} into ${mainBranch}...`);
try {
// Get confirmation
const confirmed = await this.confirmMerge(worktreeName, mainBranch, logger);
if (!confirmed) {
logger.info("Merge cancelled.");
return;
}
// Switch to main branch
await git.raw(["checkout", mainBranch]);
// Merge worktree branch
const mergeResult = await git.merge(worktreeName, `Merge branch '${worktreeName}'`);
if (!mergeResult.success && mergeResult.conflicts) {
const conflictedFiles = await git.getConflictedFiles();
logger.warn(`Merge conflicts detected in ${String(conflictedFiles.length)} file(s):`);
conflictedFiles.forEach((file) => {
logger.warn(` - ${file}`);
});
throw new GitError("Merge conflicts must be resolved manually");
}
logger.success(`Successfully merged ${worktreeName} into ${mainBranch}`);
// Return to original branch
await git.raw(["checkout", currentBranch]);
}
catch (error) {
// Try to return to original branch on error
try {
await git.raw(["checkout", currentBranch]);
}
catch {
// Ignore checkout error
}
if (error instanceof GitError) {
throw error;
}
throw new GitError(`Failed to merge: ${getErrorMessage(error)}`);
}
}
async mergeMainIntoWorktree(worktreeName, context) {
const { logger, config } = context;
const mainBranch = config?.mainBranch ?? "main";
logger.info(`Merging ${mainBranch} into ${worktreeName}...`);
try {
// Get confirmation
const confirmed = await this.confirmMerge(mainBranch, worktreeName, logger);
if (!confirmed) {
logger.info("Merge cancelled.");
return;
}
// We're already in the worktree, no need to checkout
// Create a git instance for the current directory (worktree)
const worktreeGit = createGit(process.cwd());
// Merge main branch using the worktree git instance
const mergeResult = await worktreeGit.merge(mainBranch, `Merge branch '${mainBranch}' into ${worktreeName}`);
if (!mergeResult.success && mergeResult.conflicts) {
const conflictedFiles = await worktreeGit.getConflictedFiles();
logger.warn(`Merge conflicts detected in ${String(conflictedFiles.length)} file(s):`);
conflictedFiles.forEach((file) => {
logger.warn(` - ${file}`);
});
throw new GitError("Merge conflicts must be resolved manually");
}
logger.success(`Successfully merged ${mainBranch} into ${worktreeName}`);
}
catch (error) {
if (error instanceof GitError) {
throw error;
}
throw new GitError(`Failed to merge: ${getErrorMessage(error)}`);
}
}
async confirmMerge(source, target,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_logger) {
if (process.env.WTT_NO_CONFIRM === "true") {
return true;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
const answer = await rl.question(`Merge ${source} into ${target}? (y/N): `);
return answer.toLowerCase() === "y";
}
finally {
rl.close();
}
}
}
export const mergeCommand = new Command("merge")
.description("Merge worktree changes back to main branch or update worktree from main")
.argument("[worktree]", "name of worktree to merge (default: current worktree)")
.option("-u, --update", "update worktree from main instead of merging to main")
.option("--no-fetch", "skip fetching latest changes before merge")
.option("-f, --force", "force merge even with uncommitted changes")
.action(async (worktree, options) => {
const mergeOptions = {
worktree,
update: options.update,
noFetch: options.fetch === false,
force: options.force,
verbose: options.verbose,
quiet: options.quiet,
};
const command = new MergeCommand();
await command.execute(mergeOptions);
});
//# sourceMappingURL=merge.js.map