- 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
939 lines
26 KiB
PHP
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)
|
|
];
|
|
}
|
|
}
|