Skip to main content
Glama

Git MCP Server

by Sheshiyer
working-tree-operations.ts7.61 kB
import { BaseGitOperation } from '../base/base-operation.js'; import { GitCommandBuilder } from '../../common/command-builder.js'; import { CommandResult } from '../base/operation-result.js'; import { ErrorHandler } from '../../errors/error-handler.js'; import { RepositoryValidator } from '../../utils/repository.js'; import { CommandExecutor } from '../../utils/command.js'; import { RepoStateType } from '../../caching/repository-cache.js'; import { AddOptions, CommitOptions, StatusOptions, AddResult, CommitResult, StatusResult, FileChange } from './working-tree-types.js'; /** * Handles Git add operations */ export class AddOperation extends BaseGitOperation<AddOptions, AddResult> { protected buildCommand(): GitCommandBuilder { const command = GitCommandBuilder.add(); if (this.options.all) { command.flag('all'); } if (this.options.update) { command.flag('update'); } if (this.options.ignoreRemoval) { command.flag('no-all'); } if (this.options.force) { command.withForce(); } if (this.options.dryRun) { command.flag('dry-run'); } // Add files this.options.files.forEach(file => command.arg(file)); return command; } protected parseResult(result: CommandResult): AddResult { const staged: string[] = []; const notStaged: Array<{ path: string; reason: string }> = []; // Parse output to determine which files were staged result.stdout.split('\n').forEach(line => { const match = line.match(/^add '(.+)'$/); if (match) { staged.push(match[1]); } else if (line.includes('error:')) { const errorMatch = line.match(/error: (.+?) '(.+?)'/); if (errorMatch) { notStaged.push({ path: errorMatch[2], reason: errorMatch[1] }); } } }); return { staged, notStaged: notStaged.length > 0 ? notStaged : undefined, raw: result.stdout }; } protected getCacheConfig() { return { command: 'add', stateType: RepoStateType.STATUS }; } protected validateOptions(): void { if (!this.options.files || this.options.files.length === 0) { throw ErrorHandler.handleValidationError( new Error('At least one file must be specified'), { operation: this.context.operation } ); } } } /** * Handles Git commit operations */ export class CommitOperation extends BaseGitOperation<CommitOptions, CommitResult> { protected buildCommand(): GitCommandBuilder { const command = GitCommandBuilder.commit(); command.withMessage(this.options.message); if (this.options.allowEmpty) { command.flag('allow-empty'); } if (this.options.amend) { command.flag('amend'); } if (this.options.noVerify) { command.withNoVerify(); } if (this.options.author) { command.option('author', this.options.author); } // Add specific files if provided if (this.options.files) { this.options.files.forEach(file => command.arg(file)); } return command; } protected parseResult(result: CommandResult): CommitResult { const hash = result.stdout.match(/\[.+?(\w+)\]/)?.[1] || ''; const stats = result.stdout.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); return { hash, filesChanged: stats ? parseInt(stats[1], 10) : 0, insertions: stats && stats[2] ? parseInt(stats[2], 10) : 0, deletions: stats && stats[3] ? parseInt(stats[3], 10) : 0, amended: this.options.amend || false, raw: result.stdout }; } protected getCacheConfig() { return { command: 'commit', stateType: RepoStateType.STATUS }; } protected async validateOptions(): Promise<void> { if (!this.options.message && !this.options.amend) { throw ErrorHandler.handleValidationError( new Error('Commit message is required unless amending'), { operation: this.context.operation } ); } // Verify there are staged changes unless allowing empty commits if (!this.options.allowEmpty) { const statusResult = await CommandExecutor.executeGitCommand( 'status --porcelain', this.context.operation, this.getResolvedPath() ); if (!statusResult.stdout.trim()) { throw ErrorHandler.handleValidationError( new Error('No changes to commit'), { operation: this.context.operation } ); } } } } /** * Handles Git status operations */ export class StatusOperation extends BaseGitOperation<StatusOptions, StatusResult> { protected buildCommand(): GitCommandBuilder { const command = GitCommandBuilder.status(); // Use porcelain format for consistent parsing command.flag('porcelain'); command.flag('z'); // Use NUL character as separator if (this.options.showUntracked) { command.flag('untracked-files'); } if (this.options.ignoreSubmodules) { command.option('ignore-submodules', 'all'); } if (this.options.showIgnored) { command.flag('ignored'); } if (this.options.showBranch) { command.flag('branch'); } return command; } protected async parseResult(result: CommandResult): Promise<StatusResult> { const staged: FileChange[] = []; const unstaged: FileChange[] = []; const untracked: FileChange[] = []; const ignored: FileChange[] = []; // Get current branch const branchResult = await CommandExecutor.executeGitCommand( 'rev-parse --abbrev-ref HEAD', this.context.operation, this.getResolvedPath() ); const branch = branchResult.stdout.trim(); // Parse status output const entries = result.stdout.split('\0').filter(Boolean); for (const entry of entries) { const [status, ...pathParts] = entry.split(' '); const path = pathParts.join(' '); const change: FileChange = { path, type: this.parseChangeType(status), staged: status[0] !== ' ' && status[0] !== '?', raw: status }; // Handle renamed files if (change.type === 'renamed') { const [oldPath, newPath] = path.split(' -> '); change.path = newPath; change.originalPath = oldPath; } // Categorize the change if (status === '??') { untracked.push(change); } else if (status === '!!') { ignored.push(change); } else if (change.staged) { staged.push(change); } else { unstaged.push(change); } } return { branch, clean: staged.length === 0 && unstaged.length === 0 && untracked.length === 0, staged, unstaged, untracked, ignored: this.options.showIgnored ? ignored : undefined, raw: result.stdout }; } protected getCacheConfig() { return { command: 'status', stateType: RepoStateType.STATUS }; } protected validateOptions(): void { // No specific validation needed for status } private parseChangeType(status: string): FileChange['type'] { const index = status[0]; const worktree = status[1]; if (status === '??') return 'untracked'; if (status === '!!') return 'ignored'; if (index === 'R' || worktree === 'R') return 'renamed'; if (index === 'C' || worktree === 'C') return 'copied'; if (index === 'A' || worktree === 'A') return 'added'; if (index === 'D' || worktree === 'D') return 'deleted'; return 'modified'; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Sheshiyer/git-mcp-v2'

If you have feedback or need assistance with the MCP directory API, please join our Discord server