UNPKG

@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,

208 lines (207 loc) 9.84 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 input schema for the git_push tool using Zod export const GitPushInputSchema = 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."), remote: z .string() .optional() .describe("The remote repository to push to (e.g., 'origin'). Defaults to the tracked upstream or 'origin'."), branch: z .string() .optional() .describe("The local branch to push (e.g., 'main', 'feat/new-login'). Defaults to the current branch."), remoteBranch: z .string() .optional() .describe("The remote branch to push to (e.g., 'main', 'develop'). Defaults to the same name as the local branch."), force: z .boolean() .optional() .default(false) .describe("Force the push (use with caution: `--force-with-lease` is generally safer)."), forceWithLease: z .boolean() .optional() .default(false) .describe("Force the push only if the remote ref is the expected value (`--force-with-lease`). Safer than --force."), setUpstream: z .boolean() .optional() .default(false) .describe("Set the upstream tracking configuration (`-u` or `--set-upstream`)."), tags: z .boolean() .optional() .default(false) .describe("Push all tags (`--tags`)."), delete: z .boolean() .optional() .default(false) .describe("Delete the remote branch (`--delete`). Requires `branch` to be specified. Use with caution, as deleting remote branches can affect collaborators."), // Add other relevant git push options as needed (e.g., --prune, --all) }); /** * Executes the 'git push' command and returns structured JSON output. * * @param {GitPushInput} input - The validated input object. * @param {RequestContext} context - The request context for logging and error handling. * @returns {Promise<GitPushResult>} A promise that resolves with the structured push result. * @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly. */ export async function pushGitChanges(input, context) { const operation = "pushGitChanges"; 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 }); } // Validate specific input combinations if (input.delete && !input.branch) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Cannot use --delete without specifying a branch to delete.", { context, operation }); } if (input.force && input.forceWithLease) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Cannot use --force and --force-with-lease together.", { context, operation }); } if (input.delete && (input.force || input.forceWithLease || input.setUpstream || input.tags)) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Cannot combine --delete with --force, --force-with-lease, --set-upstream, or --tags.", { context, operation }); } try { // Construct the git push command let command = `git -C "${targetPath}" push`; if (input.force) { command += " --force"; } else if (input.forceWithLease) { command += " --force-with-lease"; } if (input.setUpstream) { command += " --set-upstream"; } if (input.tags) { command += " --tags"; } if (input.delete) { command += " --delete"; } // Add remote and branch specification const remote = input.remote ? input.remote.replace(/[^a-zA-Z0-9_.\-/]/g, "") : "origin"; // Default to origin command += ` ${remote}`; if (input.branch) { const localBranch = input.branch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); command += ` ${localBranch}`; if (input.remoteBranch && !input.delete) { // remoteBranch only makes sense if not deleting const remoteBranch = input.remoteBranch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); command += `:${remoteBranch}`; } } else if (!input.tags && !input.delete) { // If no branch, tags, or delete specified, push the current branch by default // Git might handle this automatically, but being explicit can be clearer // command += ' HEAD'; // Or let git figure out the default push behavior logger.debug("No specific branch, tags, or delete specified. Relying on default git push behavior for current branch.", { ...context, operation }); } logger.debug(`Executing command: ${command}`, { ...context, operation }); // Execute command. Note: Git push often uses stderr for progress and success messages. const { stdout, stderr } = await execAsync(command); logger.debug(`Git push stdout: ${stdout}`, { ...context, operation }); if (stderr) { logger.debug(`Git push stderr: ${stderr}`, { ...context, operation }); } // Analyze stderr primarily, fallback to stdout const message = stderr.trim() || stdout.trim() || "Push command executed."; const summary = message; const rejected = message.includes("[rejected]"); const deleted = message.includes("[deleted]"); logger.info("git push executed successfully", { ...context, operation, path: targetPath, summary, rejected, deleted, }); return { success: true, message, summary, rejected, deleted }; } catch (error) { logger.error(`Failed to execute git push 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("resolve host") || errorMessage.includes("Could not read from remote repository") || errorMessage.includes("Connection timed out")) { throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Failed to connect to remote repository. Error: ${errorMessage}`, { context, operation, originalError: error }); } if (errorMessage.includes("rejected") || errorMessage.includes("failed to push some refs")) { // This might be caught here if execAsync throws due to non-zero exit code on rejection throw new McpError(BaseErrorCode.CONFLICT, `Push rejected: ${errorMessage}`, { context, operation, originalError: error }); } if (errorMessage.includes("Authentication failed") || errorMessage.includes("Permission denied")) { throw new McpError(BaseErrorCode.UNAUTHORIZED, `Authentication failed for remote repository. Error: ${errorMessage}`, { context, operation, originalError: error }); } if (errorMessage.includes("src refspec") && errorMessage.includes("does not match any")) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Push failed: Source branch/refspec does not exist locally. Error: ${errorMessage}`, { context, operation, originalError: error }); } // Generic internal error for other failures throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to push changes for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error }); } }