@nvhien20vn/git-mcp-server
Version:
212 lines (211 loc) • 12.5 kB
JavaScript
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 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) {
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 {
let command;
let isRawOutput = false; // Flag to indicate if we should parse or return raw
if (input.showSignature) {
isRawOutput = true;
command = `git -C "${targetPath}" log --show-signature`;
logger.info('Show signature requested, returning raw output.', { ...context, operation });
// Append other filters if provided
if (input.maxCount)
command += ` -n ${input.maxCount}`;
if (input.author)
command += ` --author="${input.author.replace(/[`"$&;*()|<>]/g, '')}"`;
if (input.since)
command += ` --since="${input.since.replace(/[`"$&;*()|<>]/g, '')}"`;
if (input.until)
command += ` --until="${input.until.replace(/[`"$&;*()|<>]/g, '')}"`;
if (input.branchOrFile)
command += ` ${input.branchOrFile.replace(/[`"$&;*()|<>]/g, '')}`;
}
else {
// Construct the git log command with the fixed format for parsing
command = `git -C "${targetPath}" log ${GIT_LOG_FORMAT}`;
if (input.maxCount)
command += ` -n ${input.maxCount}`;
}
if (input.author) {
// Basic sanitization for author string
const safeAuthor = input.author.replace(/[`"$&;*()|<>]/g, '');
command += ` --author="${safeAuthor}"`;
}
if (input.since) {
const safeSince = input.since.replace(/[`"$&;*()|<>]/g, '');
command += ` --since="${safeSince}"`;
}
if (input.until) {
const safeUntil = input.until.replace(/[`"$&;*()|<>]/g, '');
command += ` --until="${safeUntil}"`;
}
if (input.branchOrFile) {
const safeBranchOrFile = input.branchOrFile.replace(/[`"$&;*()|<>]/g, '');
command += ` ${safeBranchOrFile}`; // Add branch or file path at the end
}
logger.debug(`Executing command: ${command}`, { ...context, operation });
// Increase maxBuffer if logs can be large
const { stdout, stderr } = await execAsync(command, { 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(`${operation} completed successfully. ${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 });
}
}