&1', $output, $exitCode); if ($exitCode !== 0) { return [ 'error' => 'Git command failed', 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } $staged = []; $unstaged = []; $untracked = []; foreach ($output as $line) { if (empty($line)) continue; $status = substr($line, 0, 2); $file = trim(substr($line, 3)); // Staged changes (index) if ($status[0] !== ' ' && $status[0] !== '?') { $staged[] = [ 'file' => $file, 'status' => $this->getStatusDescription($status[0]) ]; } // Unstaged changes (working tree) if ($status[1] !== ' ' && $status[1] !== '?') { $unstaged[] = [ 'file' => $file, 'status' => $this->getStatusDescription($status[1]) ]; } // Untracked files if ($status === '??') { $untracked[] = $file; } } return [ 'staged' => $staged, 'unstaged' => $unstaged, 'untracked' => $untracked, 'clean' => empty($staged) && empty($unstaged) && empty($untracked), 'summary' => [ 'staged_count' => count($staged), 'unstaged_count' => count($unstaged), 'untracked_count' => count($untracked) ]; } #[McpTool( name: 'git_diff', description: 'Get diff for staged or unstaged changes', )] public function gitDiff(bool $staged = false, ?string $file = null): array { $command = 'git diff'; if ($staged) { $command .= ' --cached'; } if ($file) { $command .= ' ' . escapeshellarg($file); } $command .= ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); if ($exitCode !== 0) { return [ 'error' => 'Git diff failed', 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } return [ 'diff' => implode("\n", $output), 'staged' => $staged, 'file' => $file, 'has_changes' => !empty($output) ]; } #[McpTool( name: 'git_add', description: 'Stage files for commit' )] public function gitAdd(array $files): array { $added = []; $errors = []; foreach ($files as $file) { $output = []; $exitCode = 0; exec('git add ' . escapeshellarg($file) . ' 2>&1', $output, $exitCode); if ($exitCode === 0) { $added[] = $file; } else { $errors[$file] = implode("\n", $output); } } return [ 'added' => $added, 'errors' => $errors, 'success' => empty($errors), 'count' => count($added) ]; } #[McpTool( name: 'git_commit', description: 'Create a git commit with message' )] public function gitCommit(string $message, array $files = []): array { // Stage files if provided if (!empty($files)) { $addResult = $this->gitAdd($files); if (!$addResult['success']) { return [ 'success' => false, 'error' => 'Failed to stage files', 'details' => $addResult['errors'] ]; } } // Create commit $output = []; $exitCode = 0; $command = 'git commit -m ' . escapeshellarg($message) . ' 2>&1'; exec($command, $output, $exitCode); // Get commit hash if successful $commitHash = null; if ($exitCode === 0) { $hashOutput = []; exec('git rev-parse HEAD', $hashOutput); $commitHash = $hashOutput[0] ?? null; } return [ 'success' => $exitCode === 0, 'message' => $message, 'commit_hash' => $commitHash, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_generate_commit_message', description: 'AI-assisted commit message generation based on staged changes' )] public function generateCommitMessage(): array { // Get diff of staged changes $diffResult = $this->gitDiff(staged: true); if (empty($diffResult['diff'])) { return [ 'error' => 'No staged changes to generate message for', 'suggestion' => 'Use git_add tool to stage files first' ]; } // Analyze changes $changes = $this->analyzeDiff($diffResult['diff']); // Generate conventional commit message $type = $this->determineCommitType($changes); $scope = $this->determineScope($changes); $description = $this->generateDescription($changes); $message = $type; if ($scope) { $message .= "($scope)"; } $message .= ": $description"; return [ 'suggested_message' => $message, 'type' => $type, 'scope' => $scope, 'description' => $description, 'changes_summary' => $changes, 'conventional_commit' => true ]; } #[McpTool( name: 'git_log', description: 'Get recent commit history', )] public function gitLog(int $limit = 10): array { $output = []; $exitCode = 0; $command = sprintf( 'git log -n %d --pretty=format:"%%H|%%an|%%ae|%%ad|%%s" --date=iso 2>&1', $limit ); exec($command, $output, $exitCode); if ($exitCode !== 0) { return [ 'error' => 'Git log failed', 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } $commits = []; foreach ($output as $line) { $parts = explode('|', $line, 5); if (count($parts) === 5) { [$hash, $author, $email, $date, $message] = $parts; $commits[] = [ 'hash' => $hash, 'short_hash' => substr($hash, 0, 7), 'author' => $author, 'email' => $email, 'date' => $date, 'message' => $message ]; } } return [ 'commits' => $commits, 'count' => count($commits), 'limit' => $limit ]; } #[McpTool( name: 'git_branch_info', description: 'Get current branch and available branches' )] public function gitBranchInfo(): array { // Current branch $currentOutput = []; exec('git rev-parse --abbrev-ref HEAD 2>&1', $currentOutput, $currentExitCode); $current = $currentExitCode === 0 ? trim($currentOutput[0] ?? '') : null; // All branches $output = []; $exitCode = 0; exec('git branch -a 2>&1', $output, $exitCode); if ($exitCode !== 0) { return [ 'error' => 'Git branch command failed', 'current_branch' => $current, 'output' => implode("\n", $output) ]; } $local = []; $remote = []; foreach ($output as $line) { $branch = trim($line, '* '); if (str_starts_with($branch, 'remotes/')) { $remote[] = substr($branch, 8); // Remove 'remotes/' } else { $local[] = $branch; } } return [ 'current_branch' => $current, 'local_branches' => $local, 'remote_branches' => $remote, 'total_local' => count($local), 'total_remote' => count($remote) ]; } #[McpTool( name: 'git_changed_files', description: 'Get list of changed files with their change types' )] public function gitChangedFiles(): array { $statusResult = $this->gitStatus(); if (isset($statusResult['error'])) { return $statusResult; } $allChanges = []; foreach ($statusResult['staged'] as $change) { $allChanges[] = [ 'file' => $change['file'], 'status' => $change['status'], 'staged' => true ]; } foreach ($statusResult['unstaged'] as $change) { $allChanges[] = [ 'file' => $change['file'], 'status' => $change['status'], 'staged' => false ]; } foreach ($statusResult['untracked'] as $file) { $allChanges[] = [ 'file' => $file, 'status' => 'untracked', 'staged' => false ]; } return [ 'changes' => $allChanges, 'total' => count($allChanges), 'by_status' => $this->groupByStatus($allChanges) ]; } #[McpTool( name: 'git_stash', description: 'Stash uncommitted changes', )] public function gitStash(?string $message = null): array { $command = 'git stash'; if ($message) { $command .= ' save ' . escapeshellarg($message); } $command .= ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'message' => $message, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_stash_list', description: 'List all stashed changes' )] public function gitStashList(): array { $output = []; $exitCode = 0; exec('git stash list 2>&1', $output, $exitCode); if ($exitCode !== 0) { return [ 'error' => 'Git stash list failed', 'output' => implode("\n", $output) ]; } $stashes = []; foreach ($output as $line) { if (preg_match('/^(stash@\{(\d+)\}): (.+)$/', $line, $matches)) { $stashes[] = [ 'ref' => $matches[1], 'index' => (int)$matches[2], 'message' => $matches[3] ]; } } return [ 'stashes' => $stashes, 'count' => count($stashes) ]; } // ==================== Private Helper Methods ==================== private function getStatusDescription(string $code): string { return match ($code) { 'M' => 'modified', 'A' => 'added', 'D' => 'deleted', 'R' => 'renamed', 'C' => 'copied', 'U' => 'updated', '?' => 'untracked', default => 'unknown' }; } private function analyzeDiff(string $diff): array { $files = []; $additions = 0; $deletions = 0; $lines = explode("\n", $diff); foreach ($lines as $line) { if (str_starts_with($line, '+++')) { $file = trim(substr($line, 6)); if ($file !== '/dev/null' && !in_array($file, $files)) { $files[] = $file; } } elseif (str_starts_with($line, '+') && !str_starts_with($line, '+++')) { $additions++; } elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) { $deletions++; } } return [ 'files' => $files, 'file_count' => count($files), 'additions' => $additions, 'deletions' => $deletions, 'net_change' => $additions - $deletions ]; } private function determineCommitType(array $changes): string { $files = $changes['files']; $hasTests = false; $hasDocs = false; $hasConfig = false; $hasFeature = false; foreach ($files as $file) { if (str_contains($file, 'test') || str_contains($file, 'Test')) { $hasTests = true; } if (str_contains($file, '.md') || str_contains($file, 'docs/')) { $hasDocs = true; } if (str_contains($file, 'config') || str_contains($file, '.env')) { $hasConfig = true; } } if ($hasDocs) return 'docs'; if ($hasTests && count($files) === 1) return 'test'; if ($hasConfig) return 'chore'; if ($changes['net_change'] > 100) return 'feat'; if ($changes['deletions'] > $changes['additions']) return 'refactor'; return 'fix'; } private function determineScope(array $changes): ?string { $files = $changes['files']; if (empty($files)) return null; // Try to find common path prefix $commonParts = null; foreach ($files as $file) { $parts = explode('/', $file); if ($commonParts === null) { $commonParts = $parts; } else { $commonParts = array_intersect_assoc($commonParts, $parts); } } if ($commonParts && count($commonParts) >= 2) { // Return second level (e.g., "Queue" from "src/Framework/Queue") return $commonParts[1] ?? null; } return null; } private function generateDescription(array $changes): string { $fileCount = $changes['file_count']; $files = $changes['files']; if ($fileCount === 1) { $fileName = basename($files[0], '.php'); return "update $fileName"; } if ($fileCount <= 3) { $names = array_map(fn($f) => basename($f, '.php'), $files); return 'update ' . implode(', ', $names); } return "update $fileCount files"; } private function groupByStatus(array $changes): array { $grouped = []; foreach ($changes as $change) { $status = $change['status']; if (!isset($grouped[$status])) { $grouped[$status] = []; } $grouped[$status][] = $change['file']; } return $grouped; } // ==================== Branch Management ==================== #[McpTool( name: 'git_checkout', description: 'Checkout a branch or create new branch' )] public function gitCheckout(string $branch, bool $create = false): array { $command = 'git checkout'; if ($create) { $command .= ' -b'; } $command .= ' ' . escapeshellarg($branch) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'branch' => $branch, 'created' => $create, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_merge', description: 'Merge a branch into current branch' )] public function gitMerge(string $branch, bool $no_ff = false): array { $command = 'git merge'; if ($no_ff) { $command .= ' --no-ff'; } $command .= ' ' . escapeshellarg($branch) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); $hasConflicts = $exitCode !== 0 && str_contains(implode("\n", $output), 'CONFLICT'); return [ 'success' => $exitCode === 0, 'branch' => $branch, 'has_conflicts' => $hasConflicts, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_delete_branch', description: 'Delete a branch (local or remote)' )] public function gitDeleteBranch(string $branch, bool $force = false, bool $remote = false): array { if ($remote) { $command = 'git push origin --delete ' . escapeshellarg($branch) . ' 2>&1'; } else { $flag = $force ? '-D' : '-d'; $command = 'git branch ' . $flag . ' ' . escapeshellarg($branch) . ' 2>&1'; } $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'branch' => $branch, 'force' => $force, 'remote' => $remote, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } // ==================== Remote Operations ==================== #[McpTool( name: 'git_push', description: 'Push commits to remote repository', )] public function gitPush(?string $remote = 'origin', ?string $branch = null, bool $force = false, bool $set_upstream = false): array { $command = 'git push'; if ($set_upstream) { $command .= ' -u'; } if ($force) { $command .= ' --force-with-lease'; // Safer than --force } $command .= ' ' . escapeshellarg($remote); if ($branch) { $command .= ' ' . escapeshellarg($branch); } $command .= ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'remote' => $remote, 'branch' => $branch, 'force' => $force, 'set_upstream' => $set_upstream, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_pull', description: 'Pull changes from remote repository', )] public function gitPull(?string $remote = 'origin', ?string $branch = null, bool $rebase = false): array { $command = 'git pull'; if ($rebase) { $command .= ' --rebase'; } $command .= ' ' . escapeshellarg($remote); if ($branch) { $command .= ' ' . escapeshellarg($branch); } $command .= ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'remote' => $remote, 'branch' => $branch, 'rebase' => $rebase, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_fetch', description: 'Fetch changes from remote without merging', )] public function gitFetch(?string $remote = 'origin', bool $prune = false): array { $command = 'git fetch'; if ($prune) { $command .= ' --prune'; } $command .= ' ' . escapeshellarg($remote) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'remote' => $remote, 'prune' => $prune, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_remote_info', description: 'Get information about configured remotes')] public function gitRemoteInfo(): array { $output = []; $exitCode = 0; exec('git remote -v 2>&1', $output, $exitCode); if ($exitCode !== 0) { return [ 'error' => 'Failed to get remote info', 'output' => implode("\n", $output) ]; } $remotes = []; foreach ($output as $line) { if (preg_match('/^(\S+)\s+(\S+)\s+\((\w+)\)$/', $line, $matches)) { $name = $matches[1]; $url = $matches[2]; $type = $matches[3]; // fetch or push if (!isset($remotes[$name])) { $remotes[$name] = ['name' => $name]; } $remotes[$name][$type] = $url; } } return [ 'remotes' => array_values($remotes), 'count' => count($remotes) ]; } // ==================== Advanced Workflows ==================== #[McpTool( name: 'git_rebase', description: 'Rebase current branch onto another branch' )] public function gitRebase(string $branch, bool $interactive = false): array { if ($interactive) { return [ 'error' => 'Interactive rebase not supported in MCP (requires terminal)', 'suggestion' => 'Use git_rebase without interactive flag or run manually' ]; } $command = 'git rebase ' . escapeshellarg($branch) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); $hasConflicts = $exitCode !== 0 && str_contains(implode("\n", $output), 'CONFLICT'); return [ 'success' => $exitCode === 0, 'branch' => $branch, 'has_conflicts' => $hasConflicts, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_cherry_pick', description: 'Apply changes from specific commits', ] )] public function gitCherryPick(string $commit): array { $command = 'git cherry-pick ' . escapeshellarg($commit) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); $hasConflicts = $exitCode !== 0 && str_contains(implode("\n", $output), 'CONFLICT'); return [ 'success' => $exitCode === 0, 'commit' => $commit, 'has_conflicts' => $hasConflicts, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_reset', description: 'Reset current branch to specific commit', ] )] public function gitReset(string $commit, string $mode = 'mixed'): array { $validModes = ['soft', 'mixed', 'hard']; if (!in_array($mode, $validModes)) { return [ 'error' => 'Invalid reset mode', 'valid_modes' => $validModes ]; } $command = 'git reset --' . $mode . ' ' . escapeshellarg($commit) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'commit' => $commit, 'mode' => $mode, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_tag', description: 'Create or list git tags', )] public function gitTag(?string $name = null, ?string $message = null, ?string $commit = null): array { // List tags if no name provided if ($name === null) { $output = []; $exitCode = 0; exec('git tag -l 2>&1', $output, $exitCode); return [ 'tags' => $output, 'count' => count($output) ]; } // Create tag $command = 'git tag'; if ($message) { $command .= ' -a ' . escapeshellarg($name) . ' -m ' . escapeshellarg($message); } else { $command .= ' ' . escapeshellarg($name); } if ($commit) { $command .= ' ' . escapeshellarg($commit); } $command .= ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); return [ 'success' => $exitCode === 0, 'tag' => $name, 'message' => $message, 'commit' => $commit, 'output' => implode("\n", $output), 'exit_code' => $exitCode ]; } #[McpTool( name: 'git_conflict_status', description: 'Check for merge/rebase conflicts and get conflict details')] public function gitConflictStatus(): array { // Check if in merge/rebase state $output = []; exec('git status --porcelain 2>&1', $output); $conflicts = []; $hasConflicts = false; foreach ($output as $line) { if (str_starts_with($line, 'UU') || str_starts_with($line, 'AA') || str_starts_with($line, 'DD')) { $hasConflicts = true; $conflicts[] = trim(substr($line, 3)); } } // Check merge/rebase state $state = 'normal'; if (file_exists($this->projectRoot . '/.git/MERGE_HEAD')) { $state = 'merging'; } elseif (file_exists($this->projectRoot . '/.git/rebase-merge')) { $state = 'rebasing'; } elseif (file_exists($this->projectRoot . '/.git/CHERRY_PICK_HEAD')) { $state = 'cherry-picking'; } return [ 'has_conflicts' => $hasConflicts, 'state' => $state, 'conflicted_files' => $conflicts, 'count' => count($conflicts) ]; } }