Files
michaelschiemer/src/Framework/Mcp/Tools/GitTools.php
Michael Schiemer 5050c7d73a docs: consolidate documentation into organized structure
- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
2025-10-05 11:05:04 +02:00

939 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\Mcp\McpTool;
/**
* Git Version Control MCP Tools
*
* Provides AI-accessible Git operations for automated version control workflows.
*/
final readonly class GitTools
{
public function __construct(
private string $projectRoot
) {}
#[McpTool(
name: 'git_status',
description: 'Get current git status with staged/unstaged changes'
)]
public function gitStatus(): array
{
$output = [];
$exitCode = 0;
exec('git status --porcelain 2>&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)
];
}
}