UNPKG

@nvhien20vn/git-mcp-server

Version:
185 lines (184 loc) 12.3 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 base input schema without refinement const GitDiffInputBaseSchema = 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."), commit1: z.string().optional().describe("First commit, branch, or ref for comparison. If omitted, compares against the working tree or index (depending on 'staged')."), commit2: z.string().optional().describe("Second commit, branch, or ref for comparison. If omitted, compares commit1 against the working tree or index."), staged: z.boolean().optional().default(false).describe("Show diff of changes staged for the next commit (compares index against HEAD). Overrides commit1/commit2 if true."), file: z.string().optional().describe("Limit the diff output to a specific file path."), includeUntracked: z.boolean().optional().default(false).describe("Include untracked files in the diff output (shows their full content as new files). This is a non-standard extension."), // Add options like --name-only, --stat, context lines (-U<n>) if needed }); // Export the shape for registration export const GitDiffInputShape = GitDiffInputBaseSchema.shape; // Define the final schema with refinement for validation during execution export const GitDiffInputSchema = GitDiffInputBaseSchema.refine(data => !(data.staged && (data.commit1 || data.commit2)), { message: "Cannot use 'staged' option with specific commit references (commit1 or commit2).", path: ["staged", "commit1", "commit2"], // Indicate related fields }); /** * Executes the 'git diff' command and returns the diff output. * * @param {GitDiffInput} input - The validated input object. * @param {RequestContext} context - The request context for logging and error handling. * @returns {Promise<GitDiffResult>} A promise that resolves with the structured diff result. * @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly. */ export async function diffGitChanges(input, context) { const operation = 'diffGitChanges'; logger.debug(`Executing ${operation}`, { ...context, input }); let targetPath; try { // Resolve and sanitize the target path if (input.path && input.path !== '.') { targetPath = input.path; } 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; } 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 }); } // Basic sanitization for refs and file path const safeCommit1 = input.commit1?.replace(/[`$&;*()|<>]/g, ''); const safeCommit2 = input.commit2?.replace(/[`$&;*()|<>]/g, ''); const safeFile = input.file?.replace(/[`$&;*()|<>]/g, ''); let untrackedFilesDiff = ''; let untrackedFilesCount = 0; try { // Construct the standard git diff command let standardDiffCommand = `git -C "${targetPath}" diff`; if (input.staged) { standardDiffCommand += ' --staged'; // Or --cached } else { // Add commit references if not doing staged diff if (safeCommit1) { standardDiffCommand += ` ${safeCommit1}`; } if (safeCommit2) { standardDiffCommand += ` ${safeCommit2}`; } } // Add file path limiter if provided for standard diff // Note: `input.file` will not apply to the untracked files part unless we explicitly filter them. // For simplicity, `includeUntracked` will show all untracked files if `input.file` is also set. if (safeFile) { standardDiffCommand += ` -- "${safeFile}"`; // Use '--' to separate paths from revisions } logger.debug(`Executing standard diff command: ${standardDiffCommand}`, { ...context, operation }); const { stdout: standardStdout, stderr: standardStderr } = await execAsync(standardDiffCommand, { maxBuffer: 1024 * 1024 * 20 }); if (standardStderr) { logger.warning(`Git diff (standard) stderr: ${standardStderr}`, { ...context, operation }); } let combinedDiffOutput = standardStdout; // Handle untracked files if requested if (input.includeUntracked) { logger.debug('Including untracked files.', { ...context, operation }); const listUntrackedCommand = `git -C "${targetPath}" ls-files --others --exclude-standard`; try { const { stdout: untrackedFilesStdOut } = await execAsync(listUntrackedCommand); const untrackedFiles = untrackedFilesStdOut.trim().split('\n').filter(f => f); // Filter out empty lines if (untrackedFiles.length > 0) { logger.info(`Found ${untrackedFiles.length} untracked files.`, { ...context, operation, untrackedFiles }); let individualUntrackedDiffs = ''; for (const untrackedFile of untrackedFiles) { // Sanitize each untracked file path before using in command const safeUntrackedFile = untrackedFile.replace(/[`$&;*()|<>]/g, ''); // Skip if file path becomes empty after sanitization (unlikely but safe) if (!safeUntrackedFile) continue; const untrackedDiffCommand = `git -C "${targetPath}" diff --no-index /dev/null "${safeUntrackedFile}"`; logger.debug(`Executing diff for untracked file: ${untrackedDiffCommand}`, { ...context, operation, file: safeUntrackedFile }); try { const { stdout: untrackedFileDiffOut } = await execAsync(untrackedDiffCommand); individualUntrackedDiffs += untrackedFileDiffOut; untrackedFilesCount++; } catch (untrackedError) { // For `git diff --no-index`, a non-zero exit code (usually 1) means differences were found. // The actual diff output will be in untrackedError.stdout. if (untrackedError.stdout) { individualUntrackedDiffs += untrackedError.stdout; untrackedFilesCount++; // Log stderr if it exists, as it might contain actual error messages despite stdout having the diff if (untrackedError.stderr) { logger.warning(`Stderr while diffing untracked file ${safeUntrackedFile} (diff captured from stdout): ${untrackedError.stderr}`, { ...context, operation, file: safeUntrackedFile }); } } else { // If stdout is empty, then it's a more genuine failure. logger.warning(`Failed to diff untracked file: ${safeUntrackedFile}. Error: ${untrackedError.message}`, { ...context, operation, file: safeUntrackedFile, errorDetails: { stderr: untrackedError.stderr, stdout: untrackedError.stdout, code: untrackedError.code } }); individualUntrackedDiffs += `\n--- Diff for untracked file ${safeUntrackedFile} failed: ${untrackedError.message}\n`; } } } if (individualUntrackedDiffs) { // Add a separator if standard diff also had output if (combinedDiffOutput.trim()) { combinedDiffOutput += '\n'; } combinedDiffOutput += individualUntrackedDiffs; } } else { logger.info('No untracked files found.', { ...context, operation }); } } catch (lsFilesError) { logger.warning(`Failed to list untracked files. Error: ${lsFilesError.message}`, { ...context, operation, error: lsFilesError.stderr || lsFilesError.stdout }); // Proceed without untracked files if listing fails } } const isNoChanges = combinedDiffOutput.trim() === ''; const finalDiffOutput = isNoChanges ? 'No changes found.' : combinedDiffOutput; let message = isNoChanges ? 'No changes found.' : 'Diff generated successfully.'; if (untrackedFilesCount > 0) { message += ` Included ${untrackedFilesCount} untracked file(s).`; } logger.info(`${operation} completed successfully. ${message}`, { ...context, operation, path: targetPath }); return { success: true, diff: finalDiffOutput, message, untrackedFilesProcessed: untrackedFilesCount }; } catch (error) { // This catch block now primarily handles errors from the *standard* diff command // or catastrophic failures before/after untracked file processing. logger.error(`Failed to execute git diff operation`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, stdout: error.stdout }); const errorMessage = error.stderr || error.stdout || error.message || ''; // Handle specific error cases 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('fatal: bad object') || errorMessage.includes('unknown revision or path not in the working tree')) { const invalidRef = input.commit1 || input.commit2 || input.file; throw new McpError(BaseErrorCode.NOT_FOUND, `Invalid commit reference or file path specified: '${invalidRef}'. Error: ${errorMessage}`, { context, operation, originalError: error }); } if (errorMessage.includes('ambiguous argument')) { const ambiguousArg = input.commit1 || input.commit2 || input.file; throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Ambiguous argument provided: '${ambiguousArg}'. Error: ${errorMessage}`, { context, operation, originalError: error }); } // If the command exits with an error but stdout has content, it might still be useful (e.g., diff with conflicts) // However, standard 'git diff' usually exits 0 even with differences. Errors typically mean invalid input/repo state. // We'll treat most exec errors as failures. // Generic internal error for other failures throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to get git diff for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error }); } }