@nvhien20vn/git-mcp-server
Version:
161 lines (160 loc) • 10.2 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 BASE input schema for the git_stash tool using Zod
export const GitStashBaseSchema = 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."),
mode: z.enum(['list', 'apply', 'pop', 'drop', 'save']).describe("The stash operation to perform: 'list', 'apply', 'pop', 'drop', 'save'."),
stashRef: z.string().optional().describe("Stash reference (e.g., 'stash@{1}'). Required for 'apply', 'pop', 'drop' modes."),
message: z.string().optional().describe("Optional descriptive message used only for 'save' mode."),
// includeUntracked: z.boolean().default(false).describe("Include untracked files in 'save' mode (-u)"), // Consider adding later
// keepIndex: z.boolean().default(false).describe("Keep staged changes in 'save' mode (--keep-index)"), // Consider adding later
});
// Apply refinements and export the FINAL schema for validation within the handler
export const GitStashInputSchema = GitStashBaseSchema.refine(data => !(['apply', 'pop', 'drop'].includes(data.mode) && !data.stashRef), {
message: "A 'stashRef' (e.g., 'stash@{0}') is required for 'apply', 'pop', and 'drop' modes.",
path: ["stashRef"], // Point error to the stashRef field
});
/**
* Executes git stash commands based on the specified mode.
*
* @param {GitStashInput} input - The validated input object (validated against GitStashInputSchema).
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitStashResult>} A promise that resolves with the structured result.
* @throws {McpError} Throws an McpError for path resolution/validation failures or unexpected errors.
*/
export async function gitStashLogic(input, context) {
const operation = `gitStashLogic:${input.mode}`;
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 stash 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 });
}
// Validate stashRef format if provided (simple validation)
if (input.stashRef && !/^stash@\{[0-9]+\}$/.test(input.stashRef)) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid stash reference format: ${input.stashRef}. Expected format: stash@{n}`, { context, operation });
}
try {
let command;
let result;
switch (input.mode) {
case 'list':
command = `git -C "${targetPath}" stash list`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: listStdout } = await execAsync(command);
const stashes = listStdout.trim().split('\n')
.filter(line => line)
.map(line => {
// Improved regex to handle different stash list formats
const match = line.match(/^(stash@\{(\d+)\}):\s*(?:(?:WIP on|On)\s*([^:]+):\s*)?(.*)$/);
return match
? { ref: match[1], branch: match[3] || 'unknown', description: match[4] }
: { ref: 'unknown', branch: 'unknown', description: line }; // Fallback parsing
});
result = { success: true, mode: 'list', stashes };
break;
case 'apply':
case 'pop':
// stashRef is validated by Zod refine
const stashRefApplyPop = input.stashRef;
command = `git -C "${targetPath}" stash ${input.mode} ${stashRefApplyPop}`;
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 conflicts = /conflict/i.test(stdout) || /conflict/i.test(stderr);
const message = conflicts
? `Stash ${input.mode} resulted in conflicts that need manual resolution.`
: `Stash ${stashRefApplyPop} ${input.mode === 'apply' ? 'applied' : 'popped'} successfully.`;
logger.info(message, { ...context, operation, path: targetPath, conflicts });
result = { success: true, mode: input.mode, message, conflicts };
}
catch (applyError) {
const applyErrorMessage = applyError.stderr || applyError.message || '';
if (/conflict/i.test(applyErrorMessage)) {
logger.warning(`Stash ${input.mode} failed due to conflicts.`, { ...context, operation, path: targetPath, error: applyErrorMessage });
// Return failure but indicate conflicts
return { success: false, mode: input.mode, message: `Failed to ${input.mode} stash ${stashRefApplyPop} due to conflicts. Resolve conflicts manually.`, error: applyErrorMessage, conflicts: true };
}
// Rethrow other errors
throw applyError;
}
break;
case 'drop':
// stashRef is validated by Zod refine
const stashRefDrop = input.stashRef;
command = `git -C "${targetPath}" stash drop ${stashRefDrop}`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
await execAsync(command);
result = { success: true, mode: 'drop', message: `Dropped ${stashRefDrop} successfully.`, stashRef: stashRefDrop };
break;
case 'save':
command = `git -C "${targetPath}" stash save`;
if (input.message) {
// Ensure message is properly quoted for the shell
command += ` "${input.message.replace(/"/g, '\\"')}"`;
}
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout: saveStdout } = await execAsync(command);
const stashCreated = !/no local changes to save/i.test(saveStdout);
const saveMessage = stashCreated
? `Changes stashed successfully.` + (input.message ? ` Message: "${input.message}"` : '')
: "No local changes to save.";
result = { success: true, mode: 'save', message: saveMessage, stashCreated };
break;
default:
// Should not happen due to Zod validation
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
}
logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath });
return result;
}
catch (error) {
const errorMessage = error.stderr || error.message || '';
logger.error(`Failed to execute git stash 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 ((input.mode === 'apply' || input.mode === 'pop' || input.mode === 'drop') && /no such stash/i.test(errorMessage)) {
return { success: false, mode: input.mode, message: `Failed to ${input.mode} stash: Stash '${input.stashRef}' not found.`, error: errorMessage };
}
if ((input.mode === 'apply' || input.mode === 'pop') && /conflict/i.test(errorMessage)) {
// This case might be caught above, but double-check here
return { success: false, mode: input.mode, message: `Failed to ${input.mode} stash '${input.stashRef}' due to conflicts. Resolve conflicts manually.`, error: errorMessage, conflicts: true };
}
// Return structured failure for other git errors
return {
success: false,
mode: input.mode,
message: `Git stash ${input.mode} failed for path: ${targetPath}.`,
error: errorMessage
};
}
}