@nvhien20vn/git-mcp-server
Version:
116 lines (115 loc) • 7.57 kB
JavaScript
import { exec } from 'child_process';
import { promisify } from 'util';
import { z } from 'zod';
import { BaseErrorCode, McpError } from '../../../types-global/errors.js'; // Direct import for types-global
import { logger, sanitization } from '../../../utils/index.js'; // logger (./utils/internal/logger.js), RequestContext (./utils/internal/requestContext.js), sanitization (./utils/security/sanitization.js)
const execAsync = promisify(exec);
// Define the reset modes
const ResetModeEnum = z.enum(['soft', 'mixed', 'hard', 'merge', 'keep']);
// Define the input schema for the git_reset tool using Zod
export const GitResetInputSchema = 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."),
mode: ResetModeEnum.optional().default('mixed').describe("Reset mode: 'soft' (reset HEAD only), 'mixed' (reset HEAD and index, default), 'hard' (reset HEAD, index, and working tree - USE WITH CAUTION), 'merge', 'keep'."),
commit: z.string().optional().describe("Commit, branch, or ref to reset to. Defaults to HEAD (useful for unstaging with 'mixed' mode)."),
// file: z.string().optional().describe("If specified, reset only this file in the index (unstaging). Mode must be 'mixed' or omitted."), // Git reset [<mode>] [<tree-ish>] [--] <paths>… is complex, handle separately if needed
});
/**
* Executes the 'git reset' command and returns structured JSON output.
*
* @param {GitResetInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitResetResult>} A promise that resolves with the structured reset result.
* @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly.
*/
export async function resetGitState(input, context) {
const operation = 'resetGitState';
logger.debug(`Executing ${operation}`, { ...context, input });
// Validate input combinations (e.g., file path usage) if refinement wasn't used
// if (input.file && input.mode && input.mode !== 'mixed') {
// throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Resetting specific files is only supported with 'mixed' mode (or default).", { context, operation });
// }
// if (input.file && input.commit) {
// throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Cannot specify both a commit and file paths for reset.", { context, operation });
// }
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 commit ref
const safeCommit = input.commit?.replace(/[`$&;*()|<>]/g, '');
try {
// Construct the git reset command
let command = `git -C "${targetPath}" reset`;
if (input.mode) {
command += ` --${input.mode}`;
}
if (safeCommit) {
command += ` ${safeCommit}`;
}
// Handling file paths requires careful command construction, often without a commit ref.
// Example: `git reset HEAD -- path/to/file` or `git reset -- path/to/file` (unstages)
// For simplicity, this initial version focuses on resetting the whole HEAD/index/tree.
// Add file path logic here if needed, adjusting command structure.
logger.debug(`Executing command: ${command}`, { ...context, operation });
// Execute command. Reset output is often minimal on success, but stderr might indicate issues.
const { stdout, stderr } = await execAsync(command);
logger.info(`Git reset stdout: ${stdout}`, { ...context, operation });
if (stderr) {
// Log stderr as info, as it often contains the primary status message
logger.info(`Git reset stderr: ${stderr}`, { ...context, operation });
}
// Analyze output (primarily stderr for reset)
let message = stderr.trim() || stdout.trim() || `Reset successful (mode: ${input.mode || 'mixed'}).`; // Default success message
let changesSummary = undefined;
if (stderr.includes('Unstaged changes after reset')) {
message = `Reset successful (mode: ${input.mode || 'mixed'}).`;
changesSummary = stderr; // Include the list of unstaged changes
}
else if (stderr.match(/HEAD is now at [a-f0-9]+ /)) {
message = stderr.trim(); // Use the direct message from git
}
else if (!stderr && !stdout) {
// If no output, assume success but provide context
message = `Reset successful (mode: ${input.mode || 'mixed'}, commit: ${input.commit || 'HEAD'}). No specific output.`;
}
logger.info(`${operation} completed successfully. ${message}`, { ...context, operation, path: targetPath });
return { success: true, message, changesSummary };
}
catch (error) {
logger.error(`Failed to execute git reset 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('fatal: bad revision') || errorMessage.includes('unknown revision')) {
throw new McpError(BaseErrorCode.NOT_FOUND, `Invalid commit reference specified: '${input.commit}'. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage.includes('Cannot reset paths') && errorMessage.includes('mode')) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode ('${input.mode}') used with file paths. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (errorMessage.includes('unmerged paths')) {
throw new McpError(BaseErrorCode.CONFLICT, `Cannot reset due to unmerged files. Please resolve conflicts first. Error: ${errorMessage}`, { context, operation, originalError: error });
}
// Generic internal error for other failures
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to git reset for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
}