@nvhien20vn/git-mcp-server
Version:
218 lines (217 loc) • 10.8 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 input schema for the git_status tool using Zod
export const GitStatusInputSchema = 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."),
});
/**
* Parses the output of 'git status --porcelain=v1 -b'.
* See: https://git-scm.com/docs/git-status#_porcelain_format_version_1
*
* @param {string} porcelainOutput - The raw output from the git command.
* @returns {GitStatusResult} - Structured status information.
*/
function parseGitStatusPorcelainV1(porcelainOutput) {
const lines = porcelainOutput.trim().split('\n');
const result = {
current_branch: null,
staged_changes: {},
unstaged_changes: {},
untracked_files: [],
conflicted_files: [],
is_clean: true, // Assume clean initially
};
if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) {
return result;
}
if (lines[0].startsWith('## ')) {
const branchLine = lines.shift();
const standardBranchMatch = branchLine.match(/^## ([^ ]+?)(?:\.\.\.| \[.*\]|$)/);
const noCommitsMatch = branchLine.match(/^## No commits yet on (.+)/);
const detachedMatch = branchLine.match(/^## HEAD \(no branch\)/);
if (standardBranchMatch) {
result.current_branch = standardBranchMatch[1];
}
else if (noCommitsMatch) {
result.current_branch = `${noCommitsMatch[1]} (no commits yet)`;
}
else if (detachedMatch) {
result.current_branch = 'HEAD (detached)';
}
else {
logger.warning('Could not parse branch information from line:', { branchLine });
result.current_branch = '(unknown)';
}
}
for (const line of lines) {
if (!line)
continue;
result.is_clean = false; // Any line indicates non-clean state
const xy = line.substring(0, 2);
const file = line.substring(3);
const stagedStatusChar = xy[0];
const unstagedStatusChar = xy[1];
// Handle untracked files
if (xy === '??') {
result.untracked_files.push(file);
continue;
}
// Handle conflicted files (unmerged paths)
// DD = both deleted, AU = added by us, UD = deleted by them, UA = added by them, DU = deleted by us
// AA = both added, UU = both modified
if (stagedStatusChar === 'U' || unstagedStatusChar === 'U' ||
(stagedStatusChar === 'D' && unstagedStatusChar === 'D') ||
(stagedStatusChar === 'A' && unstagedStatusChar === 'A')) {
result.conflicted_files.push(file);
continue; // Conflicted files are handled separately and not in staged/unstaged
}
// Handle staged changes (index status)
if (stagedStatusChar !== ' ' && stagedStatusChar !== '?') {
let statusDesc = undefined;
switch (stagedStatusChar) {
case 'M':
statusDesc = 'Modified';
break;
case 'A':
statusDesc = 'Added';
break;
case 'D':
statusDesc = 'Deleted';
break;
case 'R':
statusDesc = 'Renamed';
break;
case 'C':
statusDesc = 'Copied';
break;
case 'T':
statusDesc = 'TypeChanged';
break;
}
if (statusDesc) {
if (!result.staged_changes[statusDesc]) {
result.staged_changes[statusDesc] = [];
}
result.staged_changes[statusDesc].push(file);
}
}
// Handle unstaged changes (worktree status)
if (unstagedStatusChar !== ' ' && unstagedStatusChar !== '?') {
let statusDesc = undefined;
switch (unstagedStatusChar) {
case 'M':
statusDesc = 'Modified';
break;
case 'D':
statusDesc = 'Deleted';
break;
case 'T':
statusDesc = 'TypeChanged';
break;
// 'A' (Added but not committed) is handled by '??' (untracked)
// 'R' and 'C' in worktree without being staged are complex, often appear as deleted + untracked
}
if (statusDesc) {
if (!result.unstaged_changes[statusDesc]) {
result.unstaged_changes[statusDesc] = [];
}
result.unstaged_changes[statusDesc].push(file);
}
}
}
result.is_clean = Object.keys(result.staged_changes).length === 0 &&
Object.keys(result.unstaged_changes).length === 0 &&
result.untracked_files.length === 0 &&
result.conflicted_files.length === 0;
return result;
}
/**
* Executes the 'git status --porcelain=v1 -b' command and returns structured JSON output.
*
* @param {GitStatusInput} input - The validated input object containing the repository path.
* @param {RequestContext} context - The request context for logging and error handling.
* @returns {Promise<GitStatusResult>} A promise that resolves with the structured git status.
* @throws {McpError} Throws an McpError if path resolution or validation fails, or if the git command fails.
*/
export async function getGitStatus(input, context // Add getter to context
) {
const operation = 'getGitStatus';
logger.debug(`Executing ${operation}`, { ...context, input });
let targetPath;
try {
// Resolve the target path
if (input.path && input.path !== '.') {
// Use the provided path directly
targetPath = input.path;
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
}
else {
// Path is '.' or undefined, try to get the session's working directory
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;
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
}
// Sanitize the resolved path
const sanitizedPathInfo = sanitization.sanitizePath(targetPath, { allowAbsolute: true });
logger.debug('Sanitized path', { ...context, operation, sanitizedPathInfo });
targetPath = sanitizedPathInfo.sanitizedPath; // Use the sanitized path going forward
}
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 {
// Using --porcelain=v1 for stable, scriptable output and -b for branch info
// Ensure the path passed to -C is correctly quoted for the shell
const command = `git -C "${targetPath}" status --porcelain=v1 -b`;
logger.debug(`Executing command: ${command}`, { ...context, operation });
const { stdout, stderr } = await execAsync(command);
if (stderr) {
// Log stderr as warning but proceed to parse stdout
logger.warning(`Git status command produced stderr (may be informational)`, { ...context, operation, stderr });
}
logger.info(`${operation} command executed, parsing output...`, { ...context, operation, path: targetPath });
// Parse the porcelain output
const structuredResult = parseGitStatusPorcelainV1(stdout);
// If parsing resulted in clean state but no branch, re-check branch explicitly
// This handles the case of an empty repo after init but before first commit
if (structuredResult.is_clean && !structuredResult.current_branch) {
try {
const branchCommand = `git -C "${targetPath}" rev-parse --abbrev-ref HEAD`;
const { stdout: branchStdout } = await execAsync(branchCommand);
const currentBranchName = branchStdout.trim(); // Renamed variable for clarity
if (currentBranchName && currentBranchName !== 'HEAD') {
structuredResult.current_branch = currentBranchName;
}
else if (currentBranchName === 'HEAD' && !structuredResult.current_branch) {
// If rev-parse returns HEAD and we still don't have a branch (e.g. detached from no-commits branch)
structuredResult.current_branch = 'HEAD (detached)';
}
}
catch (branchError) {
// Ignore error if rev-parse fails (e.g., still no commits)
logger.debug('Could not determine branch via rev-parse, likely no commits yet.', { ...context, operation, branchError });
}
}
logger.info(`${operation} parsed successfully`, { ...context, operation, path: targetPath });
return structuredResult; // Return the structured JSON object
}
catch (error) {
logger.error(`Failed to execute or parse git status command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr });
const errorMessage = error.stderr || error.message || '';
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 });
}
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to get git status for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
}