UNPKG

@nvhien20vn/git-mcp-server

Version:
114 lines (113 loc) 6.68 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_clean tool using Zod // No refinements needed here, but the 'force' check is critical in the logic export const GitCleanInputSchema = 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."), force: z.boolean().describe("REQUIRED confirmation to run the command. Must be explicitly set to true to perform the clean operation. If false or omitted, the command will not run."), dryRun: z.boolean().default(false).describe("Show what would be deleted without actually deleting (-n flag)."), directories: z.boolean().default(false).describe("Remove untracked directories in addition to files (-d flag)."), ignored: z.boolean().default(false).describe("Remove ignored files as well (-x flag). Use with extreme caution."), // exclude: z.string().optional().describe("Exclude files matching pattern (-e <pattern>)"), // Consider adding later }); /** * Executes the 'git clean' command to remove untracked files. * CRITICAL: Requires the 'force' parameter to be explicitly true. * * @param {GitCleanInput} input - The validated input object. * @param {RequestContext} context - The request context for logging and error handling. * @returns {Promise<GitCleanResult>} A promise that resolves with the structured result. * @throws {McpError} Throws an McpError for path/validation failures, if force=false, or unexpected errors. */ export async function gitCleanLogic(input, context) { const operation = 'gitCleanLogic'; logger.debug(`Executing ${operation}`, { ...context, input }); // --- CRITICAL SAFETY CHECK --- if (!input.force) { logger.error("Attempted to run git clean without force=true.", { ...context, operation }); throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Operation aborted: 'force' parameter must be explicitly set to true to execute 'git clean'. This is a destructive command.", { context, operation }); } // Log that the force check passed logger.warning("Executing 'git clean' with force=true. This is a destructive operation.", { ...context, operation }); 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 clean 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 { // Construct the command // Force (-f) is always added because the logic checks input.force let command = `git -C "${targetPath}" clean -f`; if (input.dryRun) { command += ' -n'; } if (input.directories) { command += ' -d'; } if (input.ignored) { command += ' -x'; } logger.debug(`Executing command: ${command}`, { ...context, operation }); const { stdout, stderr } = await execAsync(command); if (stderr) { // Log stderr as warning, as git clean might report non-fatal issues here logger.warning(`Git clean command produced stderr`, { ...context, operation, stderr }); } // Parse stdout to list affected files const filesAffected = stdout.trim().split('\n') .map(line => line.replace(/^Would remove /i, '').replace(/^Removing /i, '').trim()) // Clean up prefixes .filter(file => file); // Remove empty lines const message = input.dryRun ? `Dry run complete. Files that would be removed: ${filesAffected.length}` : `Clean operation complete. Files removed: ${filesAffected.length}`; logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath, dryRun: input.dryRun, filesAffectedCount: filesAffected.length }); return { success: true, message, filesAffected, dryRun: input.dryRun }; } catch (error) { const errorMessage = error.stderr || error.message || ''; logger.error(`Failed to execute git clean 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 }); } // Git clean usually doesn't fail with specific messages like others, // but returns non-zero exit code on general failure. // Return structured failure for other git errors return { success: false, message: `Git clean failed for path: ${targetPath}.`, error: errorMessage, dryRun: input.dryRun // Still report dryRun status }; } }