@mseep/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,
366 lines (365 loc) • 18.1 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) {
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,
});
}
}
const successMessage = `Commit successful: ${commitHash}`;
logger.info(successMessage, {
...context,
operation,
path: targetPath,
commitHash,
signed: !commitResult, // Log if it was signed (not fallback)
committedFilesCount: committedFiles.length,
});
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 });
}
// Check for pre-commit hook failures before checking for generic conflicts
if (errorMessage.toLowerCase().includes("pre-commit hook") ||
errorMessage.toLowerCase().includes("hook failed")) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Commit failed due to pre-commit hook failure. Details: ${errorMessage}`, { 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 });
}
}