UNPKG

@nvhien20vn/git-mcp-server

Version:
244 lines (243 loc) 15.9 kB
import { exec } from 'child_process'; import { promisify } from 'util'; import { z } from 'zod'; import { BaseErrorCode, McpError } from '../../../types-global/errors.js'; // Keep direct import for types-global // Import utils from barrel (logger from ../utils/internal/logger.js) import { logger } from '../../../utils/index.js'; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from '../../../utils/index.js'; // Import config to check signing flag import { config } from '../../../config/index.js'; const execAsync = promisify(exec); // Define the input schema for the git_commit tool using Zod export const GitCommitInputSchema = 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."), message: z.string().min(1).describe('Commit message. Follow Conventional Commits format: `type(scope): subject`. Example: `feat(api): add user signup endpoint`'), author: z.object({ name: z.string().describe('Author name for the commit'), email: z.string().email().describe('Author email for the commit'), }).optional().describe('Overrides the commit author information (name and email). Use only when necessary (e.g., applying external patches).'), allowEmpty: z.boolean().default(false).describe('Allow creating empty commits'), amend: z.boolean().default(false).describe('Amend the previous commit instead of creating a new one'), forceUnsignedOnFailure: z.boolean().default(false).describe('If true and signing is enabled but fails, attempt the commit without signing instead of failing.'), filesToStage: z.array(z.string().min(1)).optional().describe('Optional array of specific file paths (relative to the repository root) to stage automatically before committing. If provided, only these files will be staged.'), }); /** * Executes the 'git commit' command and returns structured JSON output. * * @param {GitCommitInput} input - The validated input object. * @param {RequestContext} context - The request context for logging and error handling. * @returns {Promise<GitCommitResult>} A promise that resolves with the structured commit result. * @throws {McpError} Throws an McpError if path resolution or validation fails, or if the git command fails unexpectedly. */ export async function commitGitChanges(input, context // Add getter to context ) { const operation = 'commitGitChanges'; logger.debug(`Executing ${operation}`, { ...context, input }); let targetPath; try { // Resolve the target path if (input.path && input.path !== '.') { targetPath = input.path; logger.debug(`Using provided path: ${targetPath}`, { ...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 }); } targetPath = workingDir; logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId }); } // Sanitize the resolved path const sanitizedPathInfo = sanitization.sanitizePath(targetPath, { allowAbsolute: true }); logger.debug('Sanitized path', { ...context, operation, sanitizedPathInfo }); targetPath = sanitizedPathInfo.sanitizedPath; // Use the sanitized path going forward } 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 { // --- Stage specific files if requested --- if (input.filesToStage && input.filesToStage.length > 0) { logger.debug(`Attempting to stage specific files: ${input.filesToStage.join(', ')}`, { ...context, operation }); try { // Correctly pass targetPath as rootDir in options object const sanitizedFiles = input.filesToStage.map(file => sanitization.sanitizePath(file, { rootDir: targetPath }).sanitizedPath); // Sanitize relative to repo root const filesToAddString = sanitizedFiles.map(file => `"${file}"`).join(' '); // Quote paths for safety const addCommand = `git -C "${targetPath}" add -- ${filesToAddString}`; logger.debug(`Executing git add command: ${addCommand}`, { ...context, operation }); await execAsync(addCommand); logger.info(`Successfully staged specified files: ${sanitizedFiles.join(', ')}`, { ...context, operation }); } catch (addError) { logger.error('Failed to stage specified files', { ...context, operation, files: input.filesToStage, error: addError.message, stderr: addError.stderr }); throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to stage files before commit: ${addError.stderr || addError.message}`, { context, operation, originalError: addError }); } } // --- End staging files --- // Escape message for shell safety const escapeShellArg = (arg) => { // Escape backslashes first, then other special chars return arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$'); }; const escapedMessage = escapeShellArg(input.message); // Construct the git commit command using the resolved targetPath let command = `git -C "${targetPath}" commit -m "${escapedMessage}"`; if (input.allowEmpty) { command += ' --allow-empty'; } if (input.amend) { command += ' --amend --no-edit'; } if (input.author) { // Escape author details as well const escapedAuthorName = escapeShellArg(input.author.name); const escapedAuthorEmail = escapeShellArg(input.author.email); // Email typically safe, but escape anyway // Use -c flags to override author for this commit, using the already escaped message command = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`; } // Append common flags (ensure they are appended to the potentially modified command from author block) if (input.allowEmpty && !command.includes(' --allow-empty')) command += ' --allow-empty'; if (input.amend && !command.includes(' --amend')) command += ' --amend --no-edit'; // Avoid double adding if author block modified command // Append signing flag if configured via GIT_SIGN_COMMITS env var if (config.gitSignCommits) { command += ' -S'; // Add signing flag (-S) logger.info('Signing enabled via GIT_SIGN_COMMITS=true, adding -S flag.', { ...context, operation }); } logger.debug(`Executing initial command attempt: ${command}`, { ...context, operation }); let stdout; let stderr; let commitResult; try { // Initial attempt (potentially with -S flag) const execResult = await execAsync(command); stdout = execResult.stdout; stderr = execResult.stderr; } catch (error) { const initialErrorMessage = error.stderr || error.message || ''; const isSigningError = initialErrorMessage.includes('gpg failed to sign') || initialErrorMessage.includes('signing failed'); if (isSigningError && input.forceUnsignedOnFailure) { logger.warning('Initial commit attempt failed due to signing error. Retrying without signing as forceUnsignedOnFailure=true.', { ...context, operation, initialError: initialErrorMessage }); // Construct command *without* -S flag, using escaped message/author const escapeShellArg = (arg) => { return arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$'); }; const escapedMessage = escapeShellArg(input.message); let unsignedCommand = `git -C "${targetPath}" commit -m "${escapedMessage}"`; if (input.allowEmpty) unsignedCommand += ' --allow-empty'; if (input.amend) unsignedCommand += ' --amend --no-edit'; if (input.author) { const escapedAuthorName = escapeShellArg(input.author.name); const escapedAuthorEmail = escapeShellArg(input.author.email); unsignedCommand = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`; // Re-append common flags if author block overwrote command if (input.allowEmpty && !unsignedCommand.includes(' --allow-empty')) unsignedCommand += ' --allow-empty'; if (input.amend && !unsignedCommand.includes(' --amend')) unsignedCommand += ' --amend --no-edit'; } logger.debug(`Executing unsigned fallback command: ${unsignedCommand}`, { ...context, operation }); try { // Retry commit without signing const fallbackResult = await execAsync(unsignedCommand); stdout = fallbackResult.stdout; stderr = fallbackResult.stderr; // Add a note to the status message indicating signing was skipped commitResult = { success: true, statusMessage: `Commit successful (unsigned, signing failed): ${stdout.trim()}`, // Default message, hash parsed below commitHash: undefined // Will be parsed below }; } catch (fallbackError) { // If the unsigned commit *also* fails, re-throw that error logger.error('Unsigned fallback commit attempt also failed.', { ...context, operation, fallbackError: fallbackError.message, stderr: fallbackError.stderr }); throw fallbackError; // Re-throw the error from the unsigned attempt } } else { // If it wasn't a signing error, or forceUnsignedOnFailure is false, re-throw the original error throw error; } } // Process result (either from initial attempt or fallback) // Check stderr first for common non-error messages if (stderr && !commitResult) { // Don't overwrite fallback message if stderr also exists if (stderr.includes('nothing to commit, working tree clean') || stderr.includes('no changes added to commit')) { const msg = stderr.includes('nothing to commit') ? 'Nothing to commit, working tree clean.' : 'No changes added to commit.'; logger.info(msg, { ...context, operation, path: targetPath }); // Use statusMessage return { success: true, statusMessage: msg, nothingToCommit: true }; } // Log other stderr as warning but continue, as commit might still succeed logger.warning(`Git commit command produced stderr`, { ...context, operation, stderr }); } // Extract commit hash (more robustly) let commitHash = undefined; const hashMatch = stdout.match(/([a-f0-9]{7,40})/); // Look for typical short or long hash if (hashMatch) { commitHash = hashMatch[1]; } else { // Fallback parsing if needed, or rely on success message logger.warning('Could not parse commit hash from stdout', { ...context, operation, stdout }); } // Use statusMessage, potentially using the one set during fallback const finalStatusMsg = commitResult?.statusMessage || (commitHash ? `Commit successful: ${commitHash}` : `Commit successful (stdout: ${stdout.trim()})`); let committedFiles = []; if (commitHash) { try { // Get the list of files included in this specific commit const showCommand = `git -C "${targetPath}" show --pretty="" --name-only ${commitHash}`; logger.debug(`Executing git show command: ${showCommand}`, { ...context, operation }); const { stdout: showStdout } = await execAsync(showCommand); committedFiles = showStdout.trim().split('\n').filter(Boolean); // Split by newline, remove empty lines logger.debug(`Retrieved committed files list for ${commitHash}`, { ...context, operation, count: committedFiles.length }); } catch (showError) { // Log a warning but don't fail the overall operation if we can't get the file list logger.warning('Failed to retrieve committed files list', { ...context, operation, commitHash, error: showError.message, stderr: showError.stderr }); } } logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath, commitHash, signed: !commitResult, committedFilesCount: committedFiles.length }); // Log if it was signed (not fallback) return { success: true, statusMessage: finalStatusMsg, // Use potentially modified message commitHash: commitHash, commitMessage: input.message, // Include the original commit message committedFiles: committedFiles // Include the list of files }; } catch (error) { // This catch block now primarily handles non-signing errors or errors from the fallback attempt logger.error(`Failed to execute git commit command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr }); const errorMessage = error.stderr || error.message || ''; // Handle specific error cases first 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 (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added to commit')) { // This might happen if git exits with error despite these messages const msg = errorMessage.includes('nothing to commit') ? 'Nothing to commit, working tree clean.' : 'No changes added to commit.'; logger.info(msg + ' (caught as error)', { ...context, operation, path: targetPath, errorMessage }); // Return success=false but indicate the reason using statusMessage return { success: false, statusMessage: msg, nothingToCommit: true }; } if (errorMessage.includes('conflicts')) { throw new McpError(BaseErrorCode.CONFLICT, `Commit failed due to unresolved conflicts in ${targetPath}`, { context, operation, originalError: error }); } // Generic internal error for other failures throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to commit changes for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error }); } }