@nvhien20vn/git-mcp-server
Version:
119 lines (118 loc) • 7.57 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_cherry-pick tool using Zod
export const GitCherryPickInputSchema = z.object({
path: z.string().min(1).optional().default('.').describe("Path to the local Git repository. Defaults to the directory set via `git_set_working_dir` for the session; set 'git_set_working_dir' if not set."),
commitRef: z.string().min(1).describe("The commit reference(s) to cherry-pick (e.g., 'hash1', 'hash1..hash3', 'branchName~3..branchName')."),
mainline: z.number().int().min(1).optional().describe("Specify the parent number (starting from 1) when cherry-picking a merge commit."),
strategy: z.enum(['recursive', 'resolve', 'ours', 'theirs', 'octopus', 'subtree']).optional().describe("Specifies a merge strategy *option* (passed via -X)."),
noCommit: z.boolean().default(false).describe("Apply the changes but do not create a commit."),
signoff: z.boolean().default(false).describe("Add a Signed-off-by line to the commit message."),
// Add options for conflict handling? (e.g., --continue, --abort, --skip) - Maybe separate tool or mode?
});
/**
* Executes the 'git cherry-pick' command.
*
* @param {GitCherryPickInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitCherryPickResult>} A promise that resolves with the structured result.
* @throws {McpError} Throws an McpError for path resolution/validation failures or unexpected errors.
*/
export async function gitCherryPickLogic(input, context) {
const operation = 'gitCherryPickLogic';
logger.debug(`Executing ${operation}`, { ...context, input });
let targetPath;
try {
// Resolve and sanitize the target path
const workingDir = context.getWorkingDirectory();
targetPath = (input.path && input.path !== '.')
? input.path
: workingDir ?? '.';
if (targetPath === '.' && !workingDir) {
logger.warning("Executing git cherry-pick in server's CWD as no path provided and no session WD set.", { ...context, operation });
targetPath = process.cwd();
}
else if (targetPath === '.' && workingDir) {
targetPath = workingDir;
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
}
else {
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
}
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 });
}
try {
let command = `git -C "${targetPath}" cherry-pick`;
if (input.mainline)
command += ` -m ${input.mainline}`;
if (input.strategy)
command += ` -X${input.strategy}`; // Note: -X for strategy options
if (input.noCommit)
command += ' --no-commit';
if (input.signoff)
command += ' --signoff';
// Add the commit reference(s) - ensure it's treated as a single argument potentially containing special chars like '..'
command += ` "${input.commitRef.replace(/"/g, '\\"')}"`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
try {
const { stdout, stderr } = await execAsync(command);
// Check stdout/stderr for conflict messages, although exit code 0 usually means success
const output = stdout + stderr;
const conflicts = /conflict/i.test(output);
const commitCreated = !input.noCommit && !conflicts;
const message = conflicts
? `Cherry-pick resulted in conflicts for commit(s) '${input.commitRef}'. Manual resolution required.`
: `Successfully cherry-picked commit(s) '${input.commitRef}'.` + (commitCreated ? ' New commit created.' : (input.noCommit ? ' Changes staged.' : ''));
logger.info(message, { ...context, operation, path: targetPath, conflicts, commitCreated });
return { success: true, message, commitCreated, conflicts };
}
catch (cherryPickError) {
const errorMessage = cherryPickError.stderr || cherryPickError.stdout || cherryPickError.message || '';
if (/conflict/i.test(errorMessage)) {
logger.warning(`Cherry-pick failed due to conflicts for commit(s) '${input.commitRef}'.`, { ...context, operation, path: targetPath, error: errorMessage });
return { success: false, message: `Failed to cherry-pick commit(s) '${input.commitRef}' due to conflicts. Resolve conflicts manually and potentially use 'git cherry-pick --continue' or '--abort'.`, error: errorMessage, conflicts: true };
}
// Rethrow other errors to be caught by the outer try-catch
throw cherryPickError;
}
}
catch (error) {
const errorMessage = error.stderr || error.stdout || error.message || '';
logger.error(`Failed to execute git cherry-pick command`, { ...context, operation, path: targetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
// Specific error handling
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 (/bad revision/i.test(errorMessage)) {
return { success: false, message: `Failed to cherry-pick: Invalid commit reference '${input.commitRef}'.`, error: errorMessage };
}
if (/after resolving the conflicts/i.test(errorMessage)) {
// This might indicate a previous conflict state
return { success: false, message: `Failed to cherry-pick: Unresolved conflicts from a previous operation exist. Resolve conflicts and use 'git cherry-pick --continue' or '--abort'.`, error: errorMessage, conflicts: true };
}
if (/your local changes would be overwritten/i.test(errorMessage)) {
return { success: false, message: `Failed to cherry-pick: Your local changes to tracked files would be overwritten. Please commit or stash them.`, error: errorMessage };
}
// Return structured failure for other git errors
return {
success: false,
message: `Git cherry-pick failed for path: ${targetPath}.`,
error: errorMessage
};
}
}