@nvhien20vn/git-mcp-server
Version:
244 lines (243 loc) • 15.9 kB
JavaScript
import { exec } from 'child_process';
import { promisify } from 'util';
import { z } from 'zod';
import { BaseErrorCode, McpError } from '../../../types-global/errors.js'; // Keep direct import for types-global
// Import utils from barrel (logger from ../utils/internal/logger.js)
import { logger } from '../../../utils/index.js';
// Import utils from barrel (sanitization from ../utils/security/sanitization.js)
import { sanitization } from '../../../utils/index.js';
// Import config to check signing flag
import { config } from '../../../config/index.js';
const execAsync = promisify(exec);
// Define the input schema for the git_commit tool using Zod
export const GitCommitInputSchema = 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."),
message: z.string().min(1).describe('Commit message. Follow Conventional Commits format: `type(scope): subject`. Example: `feat(api): add user signup endpoint`'),
author: z.object({
name: z.string().describe('Author name for the commit'),
email: z.string().email().describe('Author email for the commit'),
}).optional().describe('Overrides the commit author information (name and email). Use only when necessary (e.g., applying external patches).'),
allowEmpty: z.boolean().default(false).describe('Allow creating empty commits'),
amend: z.boolean().default(false).describe('Amend the previous commit instead of creating a new one'),
forceUnsignedOnFailure: z.boolean().default(false).describe('If true and signing is enabled but fails, attempt the commit without signing instead of failing.'),
filesToStage: z.array(z.string().min(1)).optional().describe('Optional array of specific file paths (relative to the repository root) to stage automatically before committing. If provided, only these files will be staged.'),
});
/**
* Executes the 'git commit' command and returns structured JSON output.
*
* @param {GitCommitInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitCommitResult>} A promise that resolves with the structured commit result.
* @throws {McpError} Throws an McpError if path resolution or validation fails, or if the git command fails unexpectedly.
*/
export async function commitGitChanges(input, context // Add getter to context
) {
const operation = 'commitGitChanges';
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 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 });
}
try {
// --- Stage specific files if requested ---
if (input.filesToStage && input.filesToStage.length > 0) {
logger.debug(`Attempting to stage specific files: ${input.filesToStage.join(', ')}`, { ...context, operation });
try {
// Correctly pass targetPath as rootDir in options object
const sanitizedFiles = input.filesToStage.map(file => sanitization.sanitizePath(file, { rootDir: targetPath }).sanitizedPath); // Sanitize relative to repo root
const filesToAddString = sanitizedFiles.map(file => `"${file}"`).join(' '); // Quote paths for safety
const addCommand = `git -C "${targetPath}" add -- ${filesToAddString}`;
logger.debug(`Executing git add command: ${addCommand}`, { ...context, operation });
await execAsync(addCommand);
logger.info(`Successfully staged specified files: ${sanitizedFiles.join(', ')}`, { ...context, operation });
}
catch (addError) {
logger.error('Failed to stage specified files', { ...context, operation, files: input.filesToStage, error: addError.message, stderr: addError.stderr });
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to stage files before commit: ${addError.stderr || addError.message}`, { context, operation, originalError: addError });
}
}
// --- End staging files ---
// Escape message for shell safety
const escapeShellArg = (arg) => {
// Escape backslashes first, then other special chars
return arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
};
const escapedMessage = escapeShellArg(input.message);
// Construct the git commit command using the resolved targetPath
let command = `git -C "${targetPath}" commit -m "${escapedMessage}"`;
if (input.allowEmpty) {
command += ' --allow-empty';
}
if (input.amend) {
command += ' --amend --no-edit';
}
if (input.author) {
// Escape author details as well
const escapedAuthorName = escapeShellArg(input.author.name);
const escapedAuthorEmail = escapeShellArg(input.author.email); // Email typically safe, but escape anyway
// Use -c flags to override author for this commit, using the already escaped message
command = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`;
}
// Append common flags (ensure they are appended to the potentially modified command from author block)
if (input.allowEmpty && !command.includes(' --allow-empty'))
command += ' --allow-empty';
if (input.amend && !command.includes(' --amend'))
command += ' --amend --no-edit'; // Avoid double adding if author block modified command
// Append signing flag if configured via GIT_SIGN_COMMITS env var
if (config.gitSignCommits) {
command += ' -S'; // Add signing flag (-S)
logger.info('Signing enabled via GIT_SIGN_COMMITS=true, adding -S flag.', { ...context, operation });
}
logger.debug(`Executing initial command attempt: ${command}`, { ...context, operation });
let stdout;
let stderr;
let commitResult;
try {
// Initial attempt (potentially with -S flag)
const execResult = await execAsync(command);
stdout = execResult.stdout;
stderr = execResult.stderr;
}
catch (error) {
const initialErrorMessage = error.stderr || error.message || '';
const isSigningError = initialErrorMessage.includes('gpg failed to sign') || initialErrorMessage.includes('signing failed');
if (isSigningError && input.forceUnsignedOnFailure) {
logger.warning('Initial commit attempt failed due to signing error. Retrying without signing as forceUnsignedOnFailure=true.', { ...context, operation, initialError: initialErrorMessage });
// Construct command *without* -S flag, using escaped message/author
const escapeShellArg = (arg) => {
return arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$');
};
const escapedMessage = escapeShellArg(input.message);
let unsignedCommand = `git -C "${targetPath}" commit -m "${escapedMessage}"`;
if (input.allowEmpty)
unsignedCommand += ' --allow-empty';
if (input.amend)
unsignedCommand += ' --amend --no-edit';
if (input.author) {
const escapedAuthorName = escapeShellArg(input.author.name);
const escapedAuthorEmail = escapeShellArg(input.author.email);
unsignedCommand = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`;
// Re-append common flags if author block overwrote command
if (input.allowEmpty && !unsignedCommand.includes(' --allow-empty'))
unsignedCommand += ' --allow-empty';
if (input.amend && !unsignedCommand.includes(' --amend'))
unsignedCommand += ' --amend --no-edit';
}
logger.debug(`Executing unsigned fallback command: ${unsignedCommand}`, { ...context, operation });
try {
// Retry commit without signing
const fallbackResult = await execAsync(unsignedCommand);
stdout = fallbackResult.stdout;
stderr = fallbackResult.stderr;
// Add a note to the status message indicating signing was skipped
commitResult = {
success: true,
statusMessage: `Commit successful (unsigned, signing failed): ${stdout.trim()}`, // Default message, hash parsed below
commitHash: undefined // Will be parsed below
};
}
catch (fallbackError) {
// If the unsigned commit *also* fails, re-throw that error
logger.error('Unsigned fallback commit attempt also failed.', { ...context, operation, fallbackError: fallbackError.message, stderr: fallbackError.stderr });
throw fallbackError; // Re-throw the error from the unsigned attempt
}
}
else {
// If it wasn't a signing error, or forceUnsignedOnFailure is false, re-throw the original error
throw error;
}
}
// Process result (either from initial attempt or fallback)
// Check stderr first for common non-error messages
if (stderr && !commitResult) { // Don't overwrite fallback message if stderr also exists
if (stderr.includes('nothing to commit, working tree clean') || stderr.includes('no changes added to commit')) {
const msg = stderr.includes('nothing to commit') ? 'Nothing to commit, working tree clean.' : 'No changes added to commit.';
logger.info(msg, { ...context, operation, path: targetPath });
// Use statusMessage
return { success: true, statusMessage: msg, nothingToCommit: true };
}
// Log other stderr as warning but continue, as commit might still succeed
logger.warning(`Git commit command produced stderr`, { ...context, operation, stderr });
}
// Extract commit hash (more robustly)
let commitHash = undefined;
const hashMatch = stdout.match(/([a-f0-9]{7,40})/); // Look for typical short or long hash
if (hashMatch) {
commitHash = hashMatch[1];
}
else {
// Fallback parsing if needed, or rely on success message
logger.warning('Could not parse commit hash from stdout', { ...context, operation, stdout });
}
// Use statusMessage, potentially using the one set during fallback
const finalStatusMsg = commitResult?.statusMessage || (commitHash
? `Commit successful: ${commitHash}`
: `Commit successful (stdout: ${stdout.trim()})`);
let committedFiles = [];
if (commitHash) {
try {
// Get the list of files included in this specific commit
const showCommand = `git -C "${targetPath}" show --pretty="" --name-only ${commitHash}`;
logger.debug(`Executing git show command: ${showCommand}`, { ...context, operation });
const { stdout: showStdout } = await execAsync(showCommand);
committedFiles = showStdout.trim().split('\n').filter(Boolean); // Split by newline, remove empty lines
logger.debug(`Retrieved committed files list for ${commitHash}`, { ...context, operation, count: committedFiles.length });
}
catch (showError) {
// Log a warning but don't fail the overall operation if we can't get the file list
logger.warning('Failed to retrieve committed files list', { ...context, operation, commitHash, error: showError.message, stderr: showError.stderr });
}
}
logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath, commitHash, signed: !commitResult, committedFilesCount: committedFiles.length }); // Log if it was signed (not fallback)
return {
success: true,
statusMessage: finalStatusMsg, // Use potentially modified message
commitHash: commitHash,
commitMessage: input.message, // Include the original commit message
committedFiles: committedFiles // Include the list of files
};
}
catch (error) { // This catch block now primarily handles non-signing errors or errors from the fallback attempt
logger.error(`Failed to execute git commit command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr });
const errorMessage = error.stderr || error.message || '';
// Handle specific error cases first
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('nothing to commit') || errorMessage.includes('no changes added to commit')) {
// This might happen if git exits with error despite these messages
const msg = errorMessage.includes('nothing to commit') ? 'Nothing to commit, working tree clean.' : 'No changes added to commit.';
logger.info(msg + ' (caught as error)', { ...context, operation, path: targetPath, errorMessage });
// Return success=false but indicate the reason using statusMessage
return { success: false, statusMessage: msg, nothingToCommit: true };
}
if (errorMessage.includes('conflicts')) {
throw new McpError(BaseErrorCode.CONFLICT, `Commit failed due to unresolved conflicts in ${targetPath}`, { context, operation, originalError: error });
}
// Generic internal error for other failures
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to commit changes for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
}