UNPKG

@nvhien20vn/git-mcp-server

Version:
122 lines (121 loc) 7.18 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 { sanitization } from '../../../utils/index.js'; const execAsync = promisify(exec); // Define the input schema for the git_add tool using Zod export const GitAddInputSchema = 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."), files: z.union([ z.string().min(1), z.array(z.string().min(1)) ]).default('.').describe('Files or patterns to stage, defaults to all changes (\'.\')'), }); /** * Executes the 'git add' command and returns structured JSON output. * * @param {GitAddInput} input - The validated input object. * @param {RequestContext} context - The request context for logging and error handling. * @returns {Promise<GitAddResult>} A promise that resolves with the structured add result. * @throws {McpError} Throws an McpError if path resolution or validation fails, or if the git command fails unexpectedly. */ export async function addGitFiles(input, context // Add getter to context ) { const operation = 'addGitFiles'; 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 repository 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 }); } // Prepare the files argument for the command, ensuring proper quoting let filesArg; const filesToStage = input.files; // Keep original for reporting try { if (Array.isArray(filesToStage)) { if (filesToStage.length === 0) { logger.warning('Empty array provided for files, defaulting to staging all changes.', { ...context, operation }); filesArg = '.'; // Default to staging all if array is empty } else { // Quote each file path individually filesArg = filesToStage.map(file => { const sanitizedFile = file.startsWith('-') ? `./${file}` : file; // Prefix with './' if it starts with a dash return `"${sanitizedFile.replace(/"/g, '\\"')}"`; // Escape quotes within path }).join(' '); } } else { // Single string case const sanitizedFile = filesToStage.startsWith('-') ? `./${filesToStage}` : filesToStage; // Prefix with './' if it starts with a dash filesArg = `"${sanitizedFile.replace(/"/g, '\\"')}"`; } } catch (err) { logger.error('File path validation/quoting failed', { ...context, operation, files: filesToStage, error: err }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid file path/pattern provided: ${err instanceof Error ? err.message : String(err)}`, { context, operation, originalError: err }); } // This check should ideally not be needed now due to the logic above if (!filesArg) { logger.error('Internal error: filesArg is unexpectedly empty after processing.', { ...context, operation }); throw new McpError(BaseErrorCode.INTERNAL_ERROR, 'Internal error preparing git add command.', { context, operation }); } try { // Use the resolved targetPath const command = `git -C "${targetPath}" add -- ${filesArg}`; logger.debug(`Executing command: ${command}`, { ...context, operation }); const { stdout, stderr } = await execAsync(command); if (stderr) { // Log stderr as warning, as 'git add' can produce warnings but still succeed. logger.warning(`Git add command produced stderr`, { ...context, operation, stderr }); } logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath, files: filesToStage }); const filesAddedDesc = Array.isArray(filesToStage) ? filesToStage.join(', ') : filesToStage; const reminder = "Remember to write clear, concise commit messages using the Conventional Commits format (e.g., 'feat(scope): subject')."; // Use statusMessage and add reminder return { success: true, statusMessage: `Successfully staged: ${filesAddedDesc}. ${reminder}`, filesStaged: filesToStage }; } catch (error) { logger.error(`Failed to execute git add command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr }); const errorMessage = error.stderr || error.message || ''; 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.toLowerCase().includes('did not match any files')) { // Still throw an error, but return structured info in the catch block of the registration throw new McpError(BaseErrorCode.NOT_FOUND, `Specified files/patterns did not match any files in ${targetPath}: ${filesArg}`, { context, operation, originalError: error, filesStaged: filesToStage }); } // Throw generic error for other cases throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to stage files for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error, filesStaged: filesToStage }); } }