@nvhien20vn/git-mcp-server
Version:
240 lines (239 loc) • 13.7 kB
JavaScript
import { exec } from 'child_process';
import { promisify } from 'util';
import { z } from 'zod';
import { logger, sanitization } from '../../../utils/index.js';
import { BaseErrorCode, McpError } from '../../../types-global/errors.js';
const execAsync = promisify(exec);
// Define the BASE input schema for the git_worktree tool using Zod
export const GitWorktreeBaseSchema = z.object({
path: z.string().min(1).optional().default('.').describe("Path to the local Git repository. Defaults to the directory set via `git_set_working_dir` for the session; set 'git_set_working_dir' if not set."),
mode: z.enum(['list', 'add', 'remove', 'move', 'prune']).describe("The worktree operation to perform: 'list', 'add', 'remove', 'move', 'prune'."),
// Common optional path for operations
worktreePath: z.string().min(1).optional().describe("Path of the worktree. Required for 'add', 'remove', 'move' modes."),
// 'add' mode specific
commitish: z.string().min(1).optional().describe("Branch or commit to checkout in the new worktree. Used only in 'add' mode. Defaults to HEAD."),
newBranch: z.string().min(1).optional().describe("Create a new branch in the worktree. Used only in 'add' mode."),
force: z.boolean().default(false).describe("Force the operation (e.g., for 'add' if branch exists, or 'remove' if uncommitted changes)."),
detach: z.boolean().default(false).describe("Detach HEAD in the new worktree. Used only in 'add' mode."),
// 'move' mode specific
newPath: z.string().min(1).optional().describe("The new path for the worktree. Required for 'move' mode."),
// 'prune' mode specific
verbose: z.boolean().default(false).describe("Provide more detailed output. Used in 'list' and 'prune' modes."),
dryRun: z.boolean().default(false).describe("Show what would be done without actually doing it. Used in 'prune' mode."),
expire: z.string().min(1).optional().describe("Prune entries older than this time (e.g., '1.month.ago'). Used in 'prune' mode."),
});
// Apply refinements and export the FINAL schema
export const GitWorktreeInputSchema = GitWorktreeBaseSchema.refine(data => !(data.mode === 'add' && !data.worktreePath), {
message: "A 'worktreePath' is required for 'add' mode.", path: ["worktreePath"],
}).refine(data => !(data.mode === 'remove' && !data.worktreePath), {
message: "A 'worktreePath' is required for 'remove' mode.", path: ["worktreePath"],
}).refine(data => !(data.mode === 'move' && (!data.worktreePath || !data.newPath)), {
message: "Both 'worktreePath' (old path) and 'newPath' are required for 'move' mode.", path: ["worktreePath", "newPath"],
});
/**
* Parses the output of `git worktree list --porcelain`.
*/
function parsePorcelainWorktreeList(stdout) {
const worktrees = [];
const entries = stdout.trim().split('\n\n'); // Entries are separated by double newlines
for (const entry of entries) {
const lines = entry.trim().split('\n');
let path = '';
let head = '';
let branch;
let isBare = false;
let isLocked = false;
let isPrunable = false;
let prunableReason;
for (const line of lines) {
if (line.startsWith('worktree ')) {
path = line.substring('worktree '.length);
}
else if (line.startsWith('HEAD ')) {
head = line.substring('HEAD '.length);
}
else if (line.startsWith('branch ')) {
branch = line.substring('branch '.length);
}
else if (line.startsWith('bare')) {
isBare = true;
}
else if (line.startsWith('locked')) {
isLocked = true;
const reasonMatch = line.match(/locked(?: (.+))?/);
if (reasonMatch && reasonMatch[1]) {
prunableReason = reasonMatch[1]; // Using prunableReason for lock reason too
}
}
else if (line.startsWith('prunable')) {
isPrunable = true;
const reasonMatch = line.match(/prunable(?: (.+))?/);
if (reasonMatch && reasonMatch[1]) {
prunableReason = reasonMatch[1];
}
}
}
if (path) { // Only add if a path was found
worktrees.push({ path, head, branch, isBare, isLocked, isPrunable, prunableReason });
}
}
return worktrees;
}
/**
* Executes git worktree commands.
*/
export async function gitWorktreeLogic(input, context) {
const operation = `gitWorktreeLogic:${input.mode}`;
logger.debug(`Executing ${operation}`, { ...context, input });
let targetPath;
try {
const workingDir = context.getWorkingDirectory();
targetPath = (input.path && input.path !== '.')
? input.path
: workingDir ?? '.';
if (targetPath === '.' && !workingDir) {
logger.warning("Executing git worktree in server's CWD as no path provided and no session WD set.", { ...context, operation });
targetPath = process.cwd();
}
else if (targetPath === '.' && workingDir) {
targetPath = workingDir;
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
}
else {
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
}
targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
}
catch (error) {
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
if (error instanceof McpError)
throw error;
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
}
try {
let command = `git -C "${targetPath}" worktree `;
let result;
switch (input.mode) {
case 'list':
command += 'list';
if (input.verbose)
command += ' --porcelain'; // Use porcelain for structured output
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: listStdout } = await execAsync(command);
if (input.verbose) {
const worktrees = parsePorcelainWorktreeList(listStdout);
result = { success: true, mode: 'list', worktrees };
}
else {
// Simple list output parsing (less structured)
const worktrees = listStdout.trim().split('\n').map(line => {
const parts = line.split(/\s+/);
return {
path: parts[0],
head: parts[1],
branch: parts[2]?.replace(/[\[\]]/g, ''), // Remove brackets from branch name
isBare: false, // Cannot determine from simple list
isLocked: false, // Cannot determine
isPrunable: false // Cannot determine
};
});
result = { success: true, mode: 'list', worktrees };
}
break;
case 'add':
// worktreePath is guaranteed by refine
const sanitizedWorktreePathAdd = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
command += `add `;
if (input.force)
command += '--force ';
if (input.detach)
command += '--detach ';
if (input.newBranch)
command += `-b "${input.newBranch}" `;
command += `"${sanitizedWorktreePathAdd}"`;
if (input.commitish)
command += ` "${input.commitish}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
// To get the HEAD of the new worktree, we might need another command or parse output if available
// For simplicity, we'll report success. A more robust solution might `git -C new_worktree_path rev-parse HEAD`
result = {
success: true,
mode: 'add',
worktreePath: sanitizedWorktreePathAdd,
branch: input.newBranch,
head: 'HEAD', // Placeholder, actual SHA would require another call
message: `Worktree '${sanitizedWorktreePathAdd}' added successfully.`
};
break;
case 'remove':
// worktreePath is guaranteed by refine
const sanitizedWorktreePathRemove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
command += `remove `;
if (input.force)
command += '--force ';
command += `"${sanitizedWorktreePathRemove}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: removeStdout } = await execAsync(command);
result = { success: true, mode: 'remove', worktreePath: sanitizedWorktreePathRemove, message: removeStdout.trim() || `Worktree '${sanitizedWorktreePathRemove}' removed successfully.` };
break;
case 'move':
// worktreePath and newPath are guaranteed by refine
const sanitizedOldPathMove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
const sanitizedNewPathMove = sanitization.sanitizePath(input.newPath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
command += `move "${sanitizedOldPathMove}" "${sanitizedNewPathMove}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'move', oldPath: sanitizedOldPathMove, newPath: sanitizedNewPathMove, message: `Worktree moved from '${sanitizedOldPathMove}' to '${sanitizedNewPathMove}' successfully.` };
break;
case 'prune':
command += 'prune ';
if (input.dryRun)
command += '--dry-run ';
if (input.verbose)
command += '--verbose ';
if (input.expire)
command += `--expire "${input.expire}" `;
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: pruneStdout, stderr: pruneStderr } = await execAsync(command);
// Prune often outputs to stderr even on success for verbose/dry-run
const pruneMessage = (pruneStdout.trim() || pruneStderr.trim()) || 'Worktree prune operation completed.';
result = { success: true, mode: 'prune', message: pruneMessage };
if (input.verbose && pruneStdout.trim()) {
// Attempt to parse verbose output if needed, for now just return raw message
// result.prunedItems = ...
}
break;
default:
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
}
logger.info(`${operation} executed successfully`, { ...context, path: targetPath });
return result;
}
catch (error) {
const errorMessage = error.stderr || error.stdout || (error.message || '');
logger.error(`Failed to execute git worktree command`, { ...context, path: targetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
if (errorMessage.toLowerCase().includes('not a git repository')) {
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
}
// Add more specific error handling based on `git worktree` messages
if (input.mode === 'add' && errorMessage.includes('already exists')) {
return { success: false, mode: 'add', message: `Failed to add worktree: Path '${input.worktreePath}' already exists or is a worktree.`, error: errorMessage };
}
if (input.mode === 'add' && errorMessage.includes('is a submodule')) {
return { success: false, mode: 'add', message: `Failed to add worktree: Path '${input.worktreePath}' is a submodule.`, error: errorMessage };
}
if (input.mode === 'remove' && errorMessage.includes('cannot remove the current worktree')) {
return { success: false, mode: 'remove', message: `Failed to remove worktree: Cannot remove the current worktree.`, error: errorMessage };
}
if (input.mode === 'remove' && errorMessage.includes('has unclean changes')) {
return { success: false, mode: 'remove', message: `Failed to remove worktree: '${input.worktreePath}' has uncommitted changes. Use force=true to remove.`, error: errorMessage };
}
return {
success: false,
mode: input.mode,
message: `Git worktree ${input.mode} failed for path: ${targetPath}.`,
error: errorMessage
};
}
}