@nvhien20vn/git-mcp-server
Version:
192 lines (191 loc) • 12.7 kB
JavaScript
import { exec } from 'child_process';
import { promisify } from 'util';
import { z } from 'zod';
// Import utils from barrel (logger from ../utils/internal/logger.js)
import { logger } from '../../../utils/index.js';
// Import utils from barrel (RequestContext from ../utils/internal/requestContext.js)
import { BaseErrorCode, McpError } from '../../../types-global/errors.js'; // Keep direct import for types-global
// Import utils from barrel (sanitization from ../utils/security/sanitization.js)
import { sanitization } from '../../../utils/index.js';
const execAsync = promisify(exec);
// Define the BASE input schema for the git_branch tool using Zod
export const GitBranchBaseSchema = 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', 'create', 'delete', 'rename', 'show-current']).describe("The branch operation to perform: 'list', 'create', 'delete', 'rename', 'show-current'."),
branchName: z.string().min(1).optional().describe("The name of the branch (e.g., 'feat/new-login', 'main'). Required for 'create', 'delete', 'rename' modes."),
newBranchName: z.string().min(1).optional().describe("The new name for the branch (e.g., 'fix/typo-in-readme'). Required for 'rename' mode."),
startPoint: z.string().min(1).optional().describe("Optional commit hash, tag, or existing branch name (e.g., 'main', 'v1.0.0', 'commit-hash') to start the new branch from. Used only in 'create' mode. Defaults to HEAD."),
force: z.boolean().default(false).describe("Force the operation. Use -D for delete, -M for rename, -f for create (if branch exists). Use with caution, as forcing operations can lead to data loss."),
all: z.boolean().default(false).describe("List both local and remote-tracking branches. Used only in 'list' mode."),
remote: z.boolean().default(false).describe("Act on remote-tracking branches. Used with 'list' (-r) or 'delete' (-r)."),
});
// Apply refinements and export the FINAL schema for validation within the handler
export const GitBranchInputSchema = GitBranchBaseSchema.refine(data => !(data.mode === 'create' && !data.branchName), {
message: "A 'branchName' is required for 'create' mode.", path: ["branchName"],
}).refine(data => !(data.mode === 'delete' && !data.branchName), {
message: "A 'branchName' is required for 'delete' mode.", path: ["branchName"],
}).refine(data => !(data.mode === 'rename' && (!data.branchName || !data.newBranchName)), {
message: "Both 'branchName' (old name) and 'newBranchName' are required for 'rename' mode.", path: ["branchName", "newBranchName"],
});
/**
* Executes git branch commands based on the specified mode.
*
* @param {GitBranchInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitBranchResult>} A promise that resolves with the structured result.
* @throws {McpError} Throws an McpError for path resolution/validation failures or unexpected errors.
*/
export async function gitBranchLogic(input, context) {
const operation = `gitBranchLogic:${input.mode}`;
logger.debug(`Executing ${operation}`, { ...context, input });
let targetPath;
try {
// Resolve and sanitize the target path
const workingDir = context.getWorkingDirectory();
targetPath = (input.path && input.path !== '.')
? input.path
: workingDir ?? '.';
if (targetPath === '.' && !workingDir) {
logger.warning("Executing git branch 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;
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
}
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;
let result;
switch (input.mode) {
case 'list':
command = `git -C "${targetPath}" branch --list --no-color`; // Start with basic list
if (input.all)
command += ' -a'; // Add -a if requested
else if (input.remote)
command += ' -r'; // Add -r if requested (exclusive with -a)
command += ' --verbose'; // Add verbose for commit info
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: listStdout } = await execAsync(command);
const branches = listStdout.trim().split('\n')
.filter(line => line && !line.match(/^\s*->\s*/)) // Filter out HEAD pointer lines if any
.map(line => {
const isCurrent = line.startsWith('* ');
const trimmedLine = line.replace(/^\*?\s+/, ''); // Remove leading '*' and spaces
// Determine isRemote based on the raw trimmed line BEFORE splitting
const isRemote = trimmedLine.startsWith('remotes/');
const parts = trimmedLine.split(/\s+/);
const name = parts[0]; // This might be 'remotes/origin/main' or just 'main'
const commitHash = parts[1] || undefined; // Verbose gives hash
const commitSubject = parts.slice(2).join(' ') || undefined; // Verbose gives subject
// Return the correct name (without 'remotes/' prefix if it was remote) and the isRemote flag
return {
name: isRemote ? name.split('/').slice(2).join('/') : name, // e.g., 'origin/main' or 'main'
isCurrent,
isRemote, // Use the flag determined before splitting
commitHash,
commitSubject
};
});
const currentBranch = branches.find(b => b.isCurrent)?.name;
result = { success: true, mode: 'list', branches, currentBranch };
break;
case 'create':
// branchName is validated by Zod refine
command = `git -C "${targetPath}" branch `;
if (input.force)
command += '-f ';
command += `"${input.branchName}"`; // branchName is guaranteed by refine
if (input.startPoint)
command += ` "${input.startPoint}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'create', branchName: input.branchName, message: `Branch '${input.branchName}' created successfully.` };
break;
case 'delete':
// branchName is validated by Zod refine
command = `git -C "${targetPath}" branch `;
if (input.remote)
command += '-r ';
command += input.force ? '-D ' : '-d ';
command += `"${input.branchName}"`; // branchName is guaranteed by refine
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: deleteStdout } = await execAsync(command);
result = { success: true, mode: 'delete', branchName: input.branchName, wasRemote: input.remote, message: deleteStdout.trim() || `Branch '${input.branchName}' deleted successfully.` };
break;
case 'rename':
// branchName and newBranchName validated by Zod refine
command = `git -C "${targetPath}" branch `;
command += input.force ? '-M ' : '-m ';
command += `"${input.branchName}" "${input.newBranchName}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'rename', oldBranchName: input.branchName, newBranchName: input.newBranchName, message: `Branch '${input.branchName}' renamed to '${input.newBranchName}' successfully.` };
break;
case 'show-current':
command = `git -C "${targetPath}" branch --show-current`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
try {
const { stdout: currentStdout } = await execAsync(command);
const currentBranchName = currentStdout.trim();
result = { success: true, mode: 'show-current', currentBranch: currentBranchName || null, message: currentBranchName ? `Current branch is '${currentBranchName}'.` : 'Currently in detached HEAD state.' };
}
catch (showError) {
// Handle detached HEAD state specifically if command fails
if (showError.stderr?.includes('HEAD detached')) {
result = { success: true, mode: 'show-current', currentBranch: null, message: 'Currently in detached HEAD state.' };
}
else {
throw showError; // Re-throw other errors
}
}
break;
default:
// Should not happen due to Zod validation
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
}
logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath });
return result;
}
catch (error) {
const errorMessage = error.stderr || error.stdout || error.message || ''; // stdout might contain error messages too
logger.error(`Failed to execute git branch command`, { ...context, operation, path: targetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
// Specific error handling
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 });
}
if (input.mode === 'create' && errorMessage.includes('already exists')) {
return { success: false, mode: 'create', message: `Failed to create branch: Branch '${input.branchName}' already exists. Use force=true to overwrite.`, error: errorMessage };
}
if (input.mode === 'delete' && errorMessage.includes('not found')) {
return { success: false, mode: 'delete', message: `Failed to delete branch: Branch '${input.branchName}' not found.`, error: errorMessage };
}
if (input.mode === 'delete' && errorMessage.includes('not fully merged')) {
return { success: false, mode: 'delete', message: `Failed to delete branch: Branch '${input.branchName}' is not fully merged. Use force=true to delete.`, error: errorMessage };
}
if (input.mode === 'rename' && errorMessage.includes('already exists')) {
return { success: false, mode: 'rename', message: `Failed to rename branch: Branch '${input.newBranchName}' already exists. Use force=true to overwrite.`, error: errorMessage };
}
if (input.mode === 'rename' && errorMessage.includes('not found')) {
return { success: false, mode: 'rename', message: `Failed to rename branch: Branch '${input.branchName}' not found.`, error: errorMessage };
}
// Return structured failure for other git errors
return {
success: false,
mode: input.mode,
message: `Git branch ${input.mode} failed for path: ${targetPath}.`,
error: errorMessage
};
}
}