@nvhien20vn/git-mcp-server
Version:
114 lines (113 loc) • 6.68 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_clean tool using Zod
// No refinements needed here, but the 'force' check is critical in the logic
export const GitCleanInputSchema = 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."),
force: z.boolean().describe("REQUIRED confirmation to run the command. Must be explicitly set to true to perform the clean operation. If false or omitted, the command will not run."),
dryRun: z.boolean().default(false).describe("Show what would be deleted without actually deleting (-n flag)."),
directories: z.boolean().default(false).describe("Remove untracked directories in addition to files (-d flag)."),
ignored: z.boolean().default(false).describe("Remove ignored files as well (-x flag). Use with extreme caution."),
// exclude: z.string().optional().describe("Exclude files matching pattern (-e <pattern>)"), // Consider adding later
});
/**
* Executes the 'git clean' command to remove untracked files.
* CRITICAL: Requires the 'force' parameter to be explicitly true.
*
* @param {GitCleanInput} input - The validated input object.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitCleanResult>} A promise that resolves with the structured result.
* @throws {McpError} Throws an McpError for path/validation failures, if force=false, or unexpected errors.
*/
export async function gitCleanLogic(input, context) {
const operation = 'gitCleanLogic';
logger.debug(`Executing ${operation}`, { ...context, input });
// --- CRITICAL SAFETY CHECK ---
if (!input.force) {
logger.error("Attempted to run git clean without force=true.", { ...context, operation });
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Operation aborted: 'force' parameter must be explicitly set to true to execute 'git clean'. This is a destructive command.", { context, operation });
}
// Log that the force check passed
logger.warning("Executing 'git clean' with force=true. This is a destructive operation.", { ...context, operation });
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 clean 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 {
// Construct the command
// Force (-f) is always added because the logic checks input.force
let command = `git -C "${targetPath}" clean -f`;
if (input.dryRun) {
command += ' -n';
}
if (input.directories) {
command += ' -d';
}
if (input.ignored) {
command += ' -x';
}
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout, stderr } = await execAsync(command);
if (stderr) {
// Log stderr as warning, as git clean might report non-fatal issues here
logger.warning(`Git clean command produced stderr`, { ...context, operation, stderr });
}
// Parse stdout to list affected files
const filesAffected = stdout.trim().split('\n')
.map(line => line.replace(/^Would remove /i, '').replace(/^Removing /i, '').trim()) // Clean up prefixes
.filter(file => file); // Remove empty lines
const message = input.dryRun
? `Dry run complete. Files that would be removed: ${filesAffected.length}`
: `Clean operation complete. Files removed: ${filesAffected.length}`;
logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath, dryRun: input.dryRun, filesAffectedCount: filesAffected.length });
return { success: true, message, filesAffected, dryRun: input.dryRun };
}
catch (error) {
const errorMessage = error.stderr || error.message || '';
logger.error(`Failed to execute git clean 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 });
}
// Git clean usually doesn't fail with specific messages like others,
// but returns non-zero exit code on general failure.
// Return structured failure for other git errors
return {
success: false,
message: `Git clean failed for path: ${targetPath}.`,
error: errorMessage,
dryRun: input.dryRun // Still report dryRun status
};
}
}