@nvhien20vn/git-mcp-server
Version:
214 lines (213 loc) • 13.6 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);
logger.info(`Git checkout stdout: ${stdout}`, { ...context, operation });
logger.info(`Git checkout stderr: ${stderr}`, { ...context, operation }); // Log stderr as info
// Analyze stderr primarily, fallback to stdout
let message = stderr.trim() || stdout.trim();
let success = true;
let previousBranch = undefined;
let currentBranch = undefined;
let newBranchCreated = !!input.newBranch;
let filesRestored = undefined;
let isDetachedHead = false;
let isFileCheckout = false;
// --- Initial analysis of checkout output ---
// Extract previous branch if available
const prevBranchMatch = stderr.match(/Switched to.*? from ['"]?(.*?)['"]?/);
if (prevBranchMatch) {
previousBranch = prevBranchMatch[1];
}
// Determine primary outcome from stderr/stdout
if (stderr.includes('Switched to a new branch')) {
const currentBranchMatch = stderr.match(/Switched to a new branch ['"]?(.*?)['"]?/);
currentBranch = currentBranchMatch ? currentBranchMatch[1] : input.newBranch; // Use matched or input
message = `Switched to new branch '${currentBranch}'.`;
newBranchCreated = true;
}
else if (stderr.includes('Switched to branch')) {
const currentBranchMatch = stderr.match(/Switched to branch ['"]?(.*?)['"]?/);
currentBranch = currentBranchMatch ? currentBranchMatch[1] : input.branchOrPath; // Use matched or input
message = `Switched to branch '${currentBranch}'.`;
}
else if (stderr.includes('Already on')) {
const currentBranchMatch = stderr.match(/Already on ['"]?(.*?)['"]?/);
currentBranch = currentBranchMatch ? currentBranchMatch[1] : input.branchOrPath; // Use matched or input
message = `Already on '${currentBranch}'.`;
}
else if (stderr.includes('Updated N path') || stdout.includes('Updated N path') || stderr.includes('Your branch is up to date with')) { // Checking out files or confirming current state
// Check if the input looks like file paths rather than a branch/commit
// This is heuristic - might need refinement if branch names look like paths
if (input.branchOrPath.includes('/') || input.branchOrPath.includes('.')) {
isFileCheckout = true;
message = `Restored or checked path(s): ${input.branchOrPath}`;
filesRestored = input.branchOrPath.split('\n').map(p => p.trim()).filter(p => p.length > 0);
}
else {
// Assume it was just confirming the current branch state
message = stderr.trim() || stdout.trim() || `Checked out ${input.branchOrPath}.`;
}
}
else if (stderr.includes('Previous HEAD position was') && stderr.includes('HEAD is now at')) { // Detached HEAD
message = `Checked out commit ${input.branchOrPath} (Detached HEAD state).`;
currentBranch = 'Detached HEAD';
isDetachedHead = true;
}
else if (stderr.includes('Note: switching to') || stderr.includes('Note: checking out')) { // Other detached HEAD variants
message = `Checked out ${input.branchOrPath} (Detached HEAD state).`;
currentBranch = 'Detached HEAD';
isDetachedHead = true;
}
else if (message.includes('fatal:')) {
success = false;
message = `Checkout failed: ${message}`;
logger.error(`Git checkout command indicated failure: ${message}`, { ...context, operation, stdout, stderr });
}
else if (!message && !stdout && !stderr) {
message = 'Checkout command executed silently.'; // Assume success, will verify branch below
logger.info(message, { ...context, operation });
}
else {
// Some other message, treat as informational for now
message = stderr.trim() || stdout.trim();
logger.info(`Git checkout produced message: ${message}`, { ...context, operation });
}
// --- Get definitive current branch IF checkout was successful AND not file checkout/detached HEAD ---
if (success && !isFileCheckout && !isDetachedHead) {
try {
logger.debug('Attempting to get current branch via git branch --show-current', { ...context, operation });
const statusResult = await execAsync(`git -C "${targetPath}" branch --show-current`);
const definitiveCurrentBranch = statusResult.stdout.trim();
if (definitiveCurrentBranch) {
currentBranch = definitiveCurrentBranch;
logger.info(`Confirmed current branch: ${currentBranch}`, { ...context, operation });
// Refine message if it wasn't specific before
if (message.startsWith('Checkout command executed silently') || message.startsWith('Checked out ')) {
message = `Checked out '${currentBranch}'.`;
}
else if (message.startsWith('Already on') && !message.includes(`'${currentBranch}'`)) {
message = `Already on '${currentBranch}'.`; // Update if initial parse was wrong
}
else if (message.startsWith('Switched to branch') && !message.includes(`'${currentBranch}'`)) {
message = `Switched to branch '${currentBranch}'.`; // Update if initial parse was wrong
}
}
else {
// Command succeeded but returned empty - might be detached HEAD after all?
logger.warning('git branch --show-current returned empty, possibly detached HEAD?', { ...context, operation });
// Keep potentially parsed 'Detached HEAD' or fallback to input if needed
currentBranch = currentBranch || 'Unknown (possibly detached)';
if (!message.includes('Detached HEAD'))
message += ' (Could not confirm branch name).';
}
}
catch (statusError) {
logger.warning('Could not determine current branch after checkout', { ...context, operation, error: statusError.message });
// Keep potentially parsed 'Detached HEAD' or fallback to input if needed
currentBranch = currentBranch || 'Unknown (error checking)';
if (!message.includes('Detached HEAD'))
message += ' (Error checking branch name).';
}
}
else if (success && isFileCheckout) {
// If it was a file checkout, still try to get the branch name for context
try {
const statusResult = await execAsync(`git -C "${targetPath}" branch --show-current`);
currentBranch = statusResult.stdout.trim() || 'Unknown (possibly detached)';
}
catch {
currentBranch = 'Unknown (error checking)';
}
logger.info(`Current branch after file checkout: ${currentBranch}`, { ...context, operation });
}
logger.info(`${operation} completed`, { ...context, operation, path: targetPath, success, message, currentBranch });
return { success, message, previousBranch, currentBranch, newBranchCreated, filesRestored };
}
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 });
}
}