@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,
154 lines (153 loc) • 7.49 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 input schema for the git_checkout tool using Zod
export const GitCheckoutInputSchema = 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."),
branchOrPath: z
.string()
.min(1)
.describe("The branch name (e.g., 'main'), commit hash, tag, or file path(s) (e.g., './src/file.ts') to checkout."),
newBranch: z
.string()
.optional()
.describe("Create a new branch named <new_branch> (e.g., 'feat/new-feature') and start it at <branchOrPath>."),
force: z
.boolean()
.optional()
.default(false)
.describe("Force checkout even if there are uncommitted changes (use with caution, discards local changes)."),
// Add other relevant git checkout options as needed (e.g., --track, -b for new branch shorthand)
});
/**
* Executes the 'git checkout' command and returns structured JSON output.
* Handles switching branches, creating new branches, and restoring files.
*
* @param {GitCheckoutInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitCheckoutResult>} A promise that resolves with the structured checkout result.
* @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly.
*/
export async function checkoutGit(input, context) {
const operation = "checkoutGit";
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 });
}
// Basic sanitization for branch/path argument
const safeBranchOrPath = input.branchOrPath.replace(/[`$&;*()|<>]/g, ""); // Remove potentially dangerous characters
try {
// Construct the git checkout command
let command = `git -C "${targetPath}" checkout`;
if (input.force) {
command += " --force";
}
if (input.newBranch) {
const safeNewBranch = input.newBranch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); // Sanitize new branch name
command += ` -b ${safeNewBranch}`;
}
command += ` ${safeBranchOrPath}`; // Add the target branch/path
logger.debug(`Executing command: ${command}`, { ...context, operation });
// Execute command. Checkout often uses stderr for status messages.
const { stdout, stderr } = await execAsync(command);
const message = stderr.trim() || stdout.trim();
logger.debug(`Git checkout stdout: ${stdout}`, { ...context, operation });
if (stderr) {
logger.debug(`Git checkout stderr: ${stderr}`, { ...context, operation });
}
// Get the current branch name after the checkout operation
let currentBranch;
try {
const { stdout: branchStdout } = await execAsync(`git -C "${targetPath}" branch --show-current`);
currentBranch = branchStdout.trim();
}
catch (e) {
// This can fail in detached HEAD state, which is not an error for checkout
currentBranch = "Detached HEAD";
}
const result = {
success: true,
message,
currentBranch,
newBranchCreated: !!input.newBranch,
};
logger.info("git checkout executed successfully", {
...context,
operation,
path: targetPath,
result,
});
return result;
}
catch (error) {
logger.error(`Failed to execute git checkout 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.match(/pathspec '.*?' did not match any file\(s\) known to git/)) {
throw new McpError(BaseErrorCode.NOT_FOUND, `Branch or pathspec not found: ${input.branchOrPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage.includes("already exists")) {
// e.g., trying -b with existing branch name
throw new McpError(BaseErrorCode.CONFLICT, `Cannot create new branch '${input.newBranch}': it already exists. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage.includes("Your local changes to the following files would be overwritten by checkout")) {
throw new McpError(BaseErrorCode.CONFLICT, `Checkout failed due to uncommitted local changes that would be overwritten. Please commit or stash them first, or use --force. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage.includes("invalid reference")) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid branch name or reference: ${input.branchOrPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
// Generic internal error for other failures
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to checkout for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
}