UNPKG

@cyanheads/git-mcp-server

Version:

An MCP (Model Context Protocol) server enabling LLMs and AI agents to interact with Git repositories. Provides tools for comprehensive Git operations including clone, commit, branch, diff, log, status, push, pull, merge, rebase, worktree, tag management,

285 lines (284 loc) 12.5 kB
import { execFile } 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 execFileAsync = promisify(execFile); // Define the structure for a single commit entry export const CommitEntrySchema = z.object({ hash: z.string().describe("Full commit hash"), authorName: z.string().describe("Author's name"), authorEmail: z.string().email().describe("Author's email"), timestamp: z .number() .int() .positive() .describe("Commit timestamp (Unix epoch seconds)"), subject: z.string().describe("Commit subject line"), body: z.string().optional().describe("Commit body (optional)"), }); // Define the input schema for the git_log tool using Zod export const GitLogInputSchema = 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."), maxCount: z .number() .int() .positive() .optional() .describe("Limit the number of commits to output."), author: z .string() .optional() .describe("Limit commits to those matching the specified author pattern."), since: z .string() .optional() .describe("Show commits more recent than a specific date (e.g., '2 weeks ago', '2023-01-01')."), until: z .string() .optional() .describe("Show commits older than a specific date."), branchOrFile: z .string() .optional() .describe("Show logs for a specific branch (e.g., 'main'), tag, or file path (e.g., 'src/utils/logger.ts')."), showSignature: z .boolean() .optional() .default(false) .describe("Show signature verification status for commits. Returns raw output instead of parsed JSON."), // Note: We use a fixed pretty format for reliable parsing unless showSignature is true. }); // Delimiters for parsing the custom format const FIELD_SEP = "\x1f"; // Unit Separator const RECORD_SEP = "\x1e"; // Record Separator const GIT_LOG_FORMAT = `--pretty=format:%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%at${FIELD_SEP}%s${FIELD_SEP}%b${RECORD_SEP}`; // %H=hash, %an=author name, %ae=author email, %at=timestamp, %s=subject, %b=body /** * Executes the 'git log' command with a specific format and returns structured JSON output. * * @param {GitLogInput} input - The validated input object. * @param {RequestContext} context - The request context for logging and error handling. * @returns {Promise<GitLogResult>} A promise that resolves with the structured log result (either flat or grouped). * @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly. */ export async function logGitHistory(input, context) { // Return type updated to the union const operation = "logGitHistory"; 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 }); } try { const args = ["-C", targetPath, "log"]; let isRawOutput = false; // Flag to indicate if we should parse or return raw if (input.showSignature) { isRawOutput = true; args.push("--show-signature"); logger.info("Show signature requested, returning raw output.", { ...context, operation, }); } else { args.push(GIT_LOG_FORMAT); } if (input.maxCount) { args.push(`-n${input.maxCount}`); } if (input.author) { args.push(`--author=${input.author}`); } if (input.since) { args.push(`--since=${input.since}`); } if (input.until) { args.push(`--until=${input.until}`); } if (input.branchOrFile) { args.push(input.branchOrFile); } logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); // Increase maxBuffer if logs can be large const { stdout, stderr } = await execFileAsync("git", args, { maxBuffer: 1024 * 1024 * 10, }); // 10MB buffer if (stderr) { // Log stderr as warning, as git log might sometimes use it for non-fatal info // Exception: If showing signature, stderr about allowedSignersFile is expected, treat as info if (isRawOutput && stderr.includes("allowedSignersFile needs to be configured")) { logger.info(`Git log stderr (signature verification note): ${stderr.trim()}`, { ...context, operation }); } else { logger.warning(`Git log stderr: ${stderr.trim()}`, { ...context, operation, }); } } // If raw output was requested, return it directly in the message field, omitting commits if (isRawOutput) { const message = `Raw log output (showSignature=true):\n${stdout}`; logger.info(`${operation} completed successfully (raw output).`, { ...context, operation, path: targetPath, }); // Return without the 'commits' or 'groupedCommits' field return { success: true, message: message }; } // --- Parse the structured output into a flat list first --- const flatCommits = []; const commitRecords = stdout .split(RECORD_SEP) .filter((record) => record.trim() !== ""); // Split records and remove empty ones for (const record of commitRecords) { const trimmedRecord = record.trim(); // Trim leading/trailing whitespace (like newlines) if (!trimmedRecord) continue; // Skip empty records after trimming const fields = trimmedRecord.split(FIELD_SEP); // Split the trimmed record if (fields.length >= 5) { // Need at least hash, name, email, timestamp, subject try { const commitEntry = { hash: fields[0], authorName: fields[1], authorEmail: fields[2], timestamp: parseInt(fields[3], 10), // Unix timestamp subject: fields[4], body: fields[5] || undefined, // Body might be empty }; // Validate parsed entry CommitEntrySchema.parse(commitEntry); flatCommits.push(commitEntry); } catch (parseError) { logger.warning(`Failed to parse commit record field`, { ...context, operation, fieldIndex: fields.findIndex((_, i) => i > 5), recordFragment: record.substring(0, 100), parseError, }); // Decide whether to skip the commit or throw an error } } else { logger.warning(`Skipping commit record due to unexpected number of fields (${fields.length})`, { ...context, operation, recordFragment: record.substring(0, 100) }); } } // --- Group the flat list by author --- const groupedCommitsMap = new Map(); for (const commit of flatCommits) { const authorKey = `${commit.authorName} <${commit.authorEmail}>`; const groupedInfo = { hash: commit.hash, timestamp: commit.timestamp, subject: commit.subject, body: commit.body, }; if (groupedCommitsMap.has(authorKey)) { groupedCommitsMap.get(authorKey).commits.push(groupedInfo); } else { groupedCommitsMap.set(authorKey, { authorName: commit.authorName, authorEmail: commit.authorEmail, commits: [groupedInfo], }); } } const groupedCommits = Array.from(groupedCommitsMap.values()); // --- Prepare final result --- const commitCount = flatCommits.length; const message = commitCount > 0 ? `${commitCount} commit(s) found.` : "No commits found matching criteria."; logger.info(message, { ...context, operation, path: targetPath, commitCount: commitCount, authorGroupCount: groupedCommits.length, }); return { success: true, groupedCommits, message }; // Return the grouped structure } catch (error) { logger.error(`Failed to execute git log command`, { ...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 revision")) { throw new McpError(BaseErrorCode.NOT_FOUND, `Invalid branch, tag, or revision specified: '${input.branchOrFile}'. Error: ${errorMessage}`, { context, operation, originalError: error }); } if (errorMessage.includes("fatal: ambiguous argument")) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Ambiguous argument provided (e.g., branch/tag/file conflict): '${input.branchOrFile}'. Error: ${errorMessage}`, { context, operation, originalError: error }); } // Check if it's just that no commits were found if (errorMessage.includes("does not have any commits yet")) { logger.info("Repository has no commits yet.", { ...context, operation, path: targetPath, }); // Return the grouped structure even for no commits return { success: true, groupedCommits: [], message: "Repository has no commits yet.", }; } // Generic internal error for other failures throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to get git log for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error }); } }