UNPKG

@nvhien20vn/git-mcp-server

Version:
164 lines (163 loc) 10.1 kB
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 path from 'path'; // Import path module import { sanitization } from '../../../utils/index.js'; const execAsync = promisify(exec); // Define the input schema for the git_merge tool export const GitMergeInputSchema = z.object({ path: z.string().min(1).optional().default('.').describe("Path to the Git repository. Defaults to the directory set via `git_set_working_dir` for the session; set 'git_set_working_dir' if not set."), branch: z.string().min(1).describe('The name of the branch to merge into the current branch.'), commitMessage: z.string().optional().describe('Commit message to use for the merge commit (if required, e.g., not fast-forward).'), noFf: z.boolean().default(false).describe('Create a merge commit even when the merge resolves as a fast-forward (`--no-ff`).'), squash: z.boolean().default(false).describe('Combine merged changes into a single commit (`--squash`). Requires manual commit afterwards.'), abort: z.boolean().default(false).describe('Abort the current merge process (resolves conflicts).'), // 'continue' might be too complex for initial implementation due to requiring index manipulation }); /** * Executes the 'git merge' command. * * @param {GitMergeInput} input - The validated input object. * @param {RequestContext} context - The request context. * @returns {Promise<GitMergeResult>} A promise that resolves with the structured merge result. * @throws {McpError} Throws an McpError for path issues, command failures, or unexpected errors. */ export async function gitMergeLogic(input, context) { const operation = 'gitMergeLogic'; logger.debug(`Executing ${operation}`, { ...context, input }); let targetPath; try { // Resolve the target path let resolvedPath; if (input.path && input.path !== '.') { // If a specific path is given, resolve it absolutely first // Assuming input.path could be relative *to the server's CWD* if no session WD is set, // but it's safer to require absolute paths or rely on session WD. // For simplicity, let's assume input.path is intended relative to session WD if set, or absolute otherwise. const workingDir = context.getWorkingDirectory(); if (workingDir) { resolvedPath = path.resolve(workingDir, input.path); // Resolve relative to session WD } else if (path.isAbsolute(input.path)) { resolvedPath = input.path; // Use absolute path directly } else { // If relative path given without session WD, it's ambiguous. Error out. throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Relative path provided but no working directory set for the session.", { context, operation }); } logger.debug(`Resolved provided path: ${resolvedPath}`, { ...context, operation }); } else { const workingDir = context.getWorkingDirectory(); if (!workingDir) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation }); } resolvedPath = workingDir; // Use session working directory logger.debug(`Using session working directory: ${resolvedPath}`, { ...context, operation, sessionId: context.sessionId }); } // Sanitize the resolved path // We assume the resolved path should be absolute for git commands. // sanitizePath checks for traversal and normalizes. targetPath = sanitization.sanitizePath(resolvedPath, { allowAbsolute: true }).sanitizedPath; logger.debug(`Sanitized path: ${targetPath}`, { ...context, operation }); } 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 }); } // --- Construct the git merge command --- let command = `git -C "${targetPath}" merge`; if (input.abort) { command += ' --abort'; } else { // Standard merge options if (input.noFf) command += ' --no-ff'; if (input.squash) command += ' --squash'; if (input.commitMessage && !input.squash) { // Commit message only relevant if not squashing (squash requires separate commit) command += ` -m "${input.commitMessage.replace(/"/g, '\\"')}"`; } else if (input.squash && input.commitMessage) { logger.warning("Commit message provided with --squash, but it will be ignored. Squash requires a separate commit.", { ...context, operation }); } command += ` "${input.branch.replace(/"/g, '\\"')}"`; // Add branch to merge } logger.debug(`Executing command: ${command}`, { ...context, operation }); // --- Execute and Parse --- try { const { stdout, stderr } = await execAsync(command); logger.debug(`Command stdout: ${stdout}`, { ...context, operation }); if (stderr) logger.debug(`Command stderr: ${stderr}`, { ...context, operation }); // Log stderr even on success if (input.abort) { return { success: true, message: 'Merge aborted successfully.', aborted: true }; } // Check stdout/stderr for specific success messages if (stdout.includes('Fast-forward')) { return { success: true, message: `Merge successful (fast-forward): ${stdout.trim()}`, fastForward: true }; } if (stdout.includes('Merge made by') || stdout.includes('merging')) { // Check for recursive strategy message etc. const match = stdout.match(/Merge commit '([a-f0-9]+)'/); // Try to get merge commit hash return { success: true, message: `Merge successful: ${stdout.trim()}`, mergedCommitHash: match ? match[1] : undefined, fastForward: false, // Explicitly not FF }; } if (stdout.includes('Squash commit -- not updating HEAD')) { return { success: true, message: `Merge successful (--squash): Changes staged. Manual commit required. ${stdout.trim()}`, needsManualCommit: true, }; } if (stdout.includes('Already up to date')) { return { success: true, message: `Already up to date.`, fastForward: true }; // Treat as success/FF } // If none of the above, return generic success based on exit code 0 return { success: true, message: `Merge command completed: ${stdout.trim()}` }; } catch (error) { const errorMessage = error.stderr || error.stdout || error.message || ''; // Git often puts errors in stdout/stderr logger.error(`Git merge command failed`, { ...context, operation, path: targetPath, error: error.message, output: errorMessage }); if (input.abort) { // If abort failed, it's likely there was no merge in progress if (errorMessage.includes('fatal: There is no merge to abort')) { return { success: false, message: 'No merge in progress to abort.', aborted: false }; } throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to abort merge: ${errorMessage}`, { context, operation, originalError: error }); } // Check for specific failure scenarios if (errorMessage.includes('CONFLICT')) { return { success: false, message: `Merge failed due to conflicts. Please resolve conflicts and commit. Output: ${errorMessage}`, conflict: true }; } if (errorMessage.includes('refusing to merge unrelated histories')) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Merge failed: Refusing to merge unrelated histories. Consider using '--allow-unrelated-histories'.`, { context, operation, originalError: error }); } if (errorMessage.includes('fatal: Not possible to fast-forward, aborting.')) { throw new McpError(BaseErrorCode.CONFLICT, `Merge failed: Not possible to fast-forward. Merge required.`, { context, operation, originalError: error }); } if (errorMessage.match(/fatal: '.*?' does not point to a commit/)) { throw new McpError(BaseErrorCode.NOT_FOUND, `Merge failed: Branch '${input.branch}' not found or does not point to a commit.`, { context, operation, originalError: error }); } if (errorMessage.includes('fatal: You have not concluded your merge')) { return { success: false, message: `Merge failed: Conflicts still exist from a previous merge. Resolve conflicts or abort. Output: ${errorMessage}`, conflict: true }; } if (errorMessage.includes('error: Your local changes to the following files would be overwritten by merge')) { throw new McpError(BaseErrorCode.CONFLICT, `Merge failed: Local changes would be overwritten. Please commit or stash them.`, { context, operation, originalError: error }); } // Generic error throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Git merge command failed for path ${targetPath}: ${errorMessage}`, { context, operation, originalError: error }); } }