index.ts•18.3 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { execSync } from 'child_process';
import { writeFileSync } from 'fs';
import { join } from 'path';
interface BranchOverviewArgs {
  repoPath: string;
  branches: string[];
  outputPath: string;
}
interface TimePeriodArgs {
  repoPath: string;
  branches: string[];
  timeRange: {
    start: string;
    end: string;
  };
  outputPath: string;
}
interface FileChangesArgs {
  repoPath: string;
  branches: string[];
  files: string[];
  outputPath: string;
}
interface MergeRecommendationsArgs {
  repoPath: string;
  branches: string[];
  outputPath: string;
}
class GitAnalysisServer {
  private server: Server;
  constructor() {
    this.server = new Server(
      {
        name: 'git-analysis-server',
        version: '0.1.0',
      },
      {
        capabilities: {
          tools: {},
        },
      }
    );
    this.setupToolHandlers();
    
    this.server.onerror = (error) => console.error('[MCP Error]', error);
    process.on('SIGINT', async () => {
      await this.server.close();
      process.exit(0);
    });
  }
  private setupToolHandlers() {
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: 'get_branch_overview',
          description: 'Get high-level overview of branch states and relationships',
          inputSchema: {
            type: 'object',
            properties: {
              repoPath: {
                type: 'string',
                description: 'Path to git repository',
              },
              branches: {
                type: 'array',
                items: { type: 'string' },
                description: 'Branches to analyze',
              },
              outputPath: {
                type: 'string',
                description: 'Path to write analysis output',
              },
            },
            required: ['repoPath', 'branches', 'outputPath'],
          },
        },
        {
          name: 'analyze_time_period',
          description: 'Analyze detailed development activity in a specific time period',
          inputSchema: {
            type: 'object',
            properties: {
              repoPath: {
                type: 'string',
                description: 'Path to git repository',
              },
              branches: {
                type: 'array',
                items: { type: 'string' },
                description: 'Branches to analyze',
              },
              timeRange: {
                type: 'object',
                properties: {
                  start: { type: 'string' },
                  end: { type: 'string' },
                },
                required: ['start', 'end'],
              },
              outputPath: {
                type: 'string',
                description: 'Path to write analysis output',
              },
            },
            required: ['repoPath', 'branches', 'timeRange', 'outputPath'],
          },
        },
        {
          name: 'analyze_file_changes',
          description: 'Analyze changes to specific files across branches',
          inputSchema: {
            type: 'object',
            properties: {
              repoPath: {
                type: 'string',
                description: 'Path to git repository',
              },
              branches: {
                type: 'array',
                items: { type: 'string' },
                description: 'Branches to analyze',
              },
              files: {
                type: 'array',
                items: { type: 'string' },
                description: 'Files to analyze',
              },
              outputPath: {
                type: 'string',
                description: 'Path to write analysis output',
              },
            },
            required: ['repoPath', 'branches', 'files', 'outputPath'],
          },
        },
        {
          name: 'get_merge_recommendations',
          description: 'Get detailed merge strategy recommendations',
          inputSchema: {
            type: 'object',
            properties: {
              repoPath: {
                type: 'string',
                description: 'Path to git repository',
              },
              branches: {
                type: 'array',
                items: { type: 'string' },
                description: 'Branches to analyze',
              },
              outputPath: {
                type: 'string',
                description: 'Path to write analysis output',
              },
            },
            required: ['repoPath', 'branches', 'outputPath'],
          },
        },
      ],
    }));
    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      try {
        switch (request.params.name) {
          case 'get_branch_overview': {
            const args = request.params.arguments as BranchOverviewArgs;
            if (!args?.repoPath || !args?.branches || !args?.outputPath) {
              throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
            }
            return await this.handleBranchOverview(args);
          }
          case 'analyze_time_period': {
            const args = request.params.arguments as TimePeriodArgs;
            if (!args?.repoPath || !args?.branches || !args?.timeRange || !args?.outputPath) {
              throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
            }
            return await this.handleTimePeriodAnalysis(args);
          }
          case 'analyze_file_changes': {
            const args = request.params.arguments as FileChangesArgs;
            if (!args?.repoPath || !args?.branches || !args?.files || !args?.outputPath) {
              throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
            }
            return await this.handleFileChangesAnalysis(args);
          }
          case 'get_merge_recommendations': {
            const args = request.params.arguments as MergeRecommendationsArgs;
            if (!args?.repoPath || !args?.branches || !args?.outputPath) {
              throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters');
            }
            return await this.handleMergeRecommendations(args);
          }
          default:
            throw new McpError(
              ErrorCode.MethodNotFound,
              `Unknown tool: ${request.params.name}`
            );
        }
      } catch (error) {
        return {
          content: [
            {
              type: 'text',
              text: `Git analysis error: ${error instanceof Error ? error.message : String(error)}`,
            },
          ],
          isError: true,
        };
      }
    });
  }
  private async handleBranchOverview(args: BranchOverviewArgs) {
    const overview = args.branches.map(branch => {
      const lastCommit = this.getLastCommit(args.repoPath, branch);
      const commitCount = this.getCommitCount(args.repoPath, branch);
      const mergeBase = args.branches.map(otherBranch => {
        if (otherBranch === branch) return null;
        return {
          branch: otherBranch,
          base: this.getMergeBase(args.repoPath, branch, otherBranch),
        };
      }).filter((base): base is NonNullable<typeof base> => base !== null);
      return {
        branch,
        lastCommit,
        commitCount,
        mergeBase,
      };
    });
    const analysis = {
      overview,
      summary: this.generateOverviewSummary(overview),
    };
    writeFileSync(args.outputPath, JSON.stringify(analysis, null, 2));
    return {
      content: [
        {
          type: 'text',
          text: `Branch analysis written to ${args.outputPath}`,
        },
      ],
    };
  }
  private async handleTimePeriodAnalysis(args: TimePeriodArgs) {
    const analysis = args.branches.map(branch => {
      const commits = this.getCommitsInRange(args.repoPath, branch, args.timeRange);
      return {
        branch,
        commits,
        activitySummary: this.summarizeActivity(commits),
      };
    });
    const result = {
      analysis,
      summary: this.generateTimePeriodSummary(analysis),
    };
    writeFileSync(args.outputPath, JSON.stringify(result, null, 2));
    return {
      content: [
        {
          type: 'text',
          text: `Time period analysis written to ${args.outputPath}`,
        },
      ],
    };
  }
  private async handleFileChangesAnalysis(args: FileChangesArgs) {
    const analysis = args.files.map(file => {
      const changes = args.branches.map(branch => ({
        branch,
        history: this.getFileHistory(args.repoPath, branch, file),
      }));
      return {
        file,
        changes,
        conflicts: this.analyzeConflicts(changes),
      };
    });
    const result = {
      analysis,
      summary: this.generateFileChangesSummary(analysis),
    };
    writeFileSync(args.outputPath, JSON.stringify(result, null, 2));
    return {
      content: [
        {
          type: 'text',
          text: `File changes analysis written to ${args.outputPath}`,
        },
      ],
    };
  }
  private async handleMergeRecommendations(args: MergeRecommendationsArgs) {
    const recommendations = {
      strategy: this.determineMergeStrategy(args.repoPath, args.branches),
      conflictRisks: this.assessConflictRisks(args.repoPath, args.branches),
      steps: this.generateMergeSteps(args.repoPath, args.branches),
    };
    writeFileSync(args.outputPath, JSON.stringify(recommendations, null, 2));
    return {
      content: [
        {
          type: 'text',
          text: `Merge recommendations written to ${args.outputPath}`,
        },
      ],
    };
  }
  private getLastCommit(repoPath: string, branch: string) {
    const output = execSync(
      `cd "${repoPath}" && git log -1 --format="%H|%aI|%s" ${branch}`,
      { encoding: 'utf8' }
    ).trim();
    const [hash, date, message] = output.split('|');
    return { hash, date, message, branch };
  }
  private getCommitCount(repoPath: string, branch: string): number {
    return parseInt(
      execSync(
        `cd "${repoPath}" && git rev-list --count ${branch}`,
        { encoding: 'utf8' }
      ).trim(),
      10
    );
  }
  private getMergeBase(repoPath: string, branch1: string, branch2: string): string {
    return execSync(
      `cd "${repoPath}" && git merge-base ${branch1} ${branch2}`,
      { encoding: 'utf8' }
    ).trim();
  }
  private getCommitsInRange(
    repoPath: string,
    branch: string,
    timeRange: { start: string; end: string }
  ) {
    const output = execSync(
      `cd "${repoPath}" && git log --format="%H|%aI|%s" ` +
      `--after="${timeRange.start}" --before="${timeRange.end}" ${branch}`,
      { encoding: 'utf8' }
    );
    return output.trim().split('\n').filter(Boolean).map(line => {
      const [hash, date, message] = line.split('|');
      return { hash, date, message, branch };
    });
  }
  private getFileHistory(repoPath: string, branch: string, file: string) {
    const output = execSync(
      `cd "${repoPath}" && git log --format="%H|%aI|%s" ${branch} -- ${file}`,
      { encoding: 'utf8' }
    );
    return output.trim().split('\n').filter(Boolean).map(line => {
      const [hash, date, message] = line.split('|');
      return { hash, date, message, branch };
    });
  }
  private summarizeActivity(commits: Array<{ hash: string; date: string; message: string; branch: string }>) {
    return {
      totalCommits: commits.length,
      firstCommit: commits[commits.length - 1],
      lastCommit: commits[0],
      commitTypes: this.categorizeCommits(commits),
    };
  }
  private categorizeCommits(commits: Array<{ message: string }>) {
    const categories = {
      feature: 0,
      fix: 0,
      refactor: 0,
      docs: 0,
      other: 0,
    };
    commits.forEach(({ message }) => {
      if (message.match(/^feat|^add/i)) categories.feature++;
      else if (message.match(/^fix|^bug/i)) categories.fix++;
      else if (message.match(/^refactor|^style|^chore/i)) categories.refactor++;
      else if (message.match(/^docs/i)) categories.docs++;
      else categories.other++;
    });
    return categories;
  }
  private analyzeConflicts(branchChanges: Array<{ branch: string; history: Array<{ hash: string; date: string; message: string }> }>) {
    const overlaps = this.findOverlappingChanges(branchChanges);
    return {
      riskLevel: this.assessRiskLevel(overlaps),
      reasons: this.generateConflictReasons(overlaps),
    };
  }
  private findOverlappingChanges(branchChanges: Array<{ branch: string; history: Array<{ date: string }> }>) {
    const timeRanges = branchChanges.map(({ branch, history }) => ({
      branch,
      start: history[history.length - 1]?.date,
      end: history[0]?.date,
    }));
    return timeRanges.flatMap((range1, i) => 
      timeRanges.slice(i + 1).map(range2 => ({
        branches: [range1.branch, range2.branch],
        overlaps: this.datesOverlap(
          new Date(range1.start),
          new Date(range1.end),
          new Date(range2.start),
          new Date(range2.end)
        ),
      }))
    ).filter(({ overlaps }) => overlaps);
  }
  private datesOverlap(start1: Date, end1: Date, start2: Date, end2: Date): boolean {
    return start1 <= end2 && start2 <= end1;
  }
  private assessRiskLevel(overlaps: Array<{ branches: string[] }>) {
    if (overlaps.length === 0) return 'low';
    if (overlaps.length <= 2) return 'medium';
    return 'high';
  }
  private generateConflictReasons(overlaps: Array<{ branches: string[] }>) {
    return overlaps.map(({ branches }) => 
      `Parallel development detected between ${branches.join(' and ')}`
    );
  }
  private determineMergeStrategy(repoPath: string, branches: string[]) {
    const commitCounts = branches.map(branch => ({
      branch,
      count: this.getCommitCount(repoPath, branch),
    }));
    const baseChoice = commitCounts.reduce((a, b) => 
      a.count > b.count ? a : b
    );
    return {
      recommendedBase: baseChoice.branch,
      approach: 'cherry-pick',
      reasoning: [
        `${baseChoice.branch} has the most changes (${baseChoice.count} commits)`,
        'Cherry-pick approach allows for selective integration',
      ],
    };
  }
  private assessConflictRisks(repoPath: string, branches: string[]) {
    const changedFiles = new Map<string, string[]>();
    branches.forEach(branch => {
      const files = execSync(
        `cd "${repoPath}" && git diff --name-only $(git merge-base ${branches[0]} ${branch})..${branch}`,
        { encoding: 'utf8' }
      )
        .trim()
        .split('\n')
        .filter(Boolean);
      files.forEach(file => {
        const current = changedFiles.get(file) || [];
        changedFiles.set(file, [...current, branch]);
      });
    });
    const hotspots = Array.from(changedFiles.entries())
      .filter(([_, branches]) => branches.length > 1)
      .map(([file]) => file);
    return {
      overallRisk: hotspots.length > 5 ? 'high' : hotspots.length > 0 ? 'medium' : 'low',
      hotspots,
      recommendations: [
        'Review changes in hotspots first',
        'Consider creating integration tests for modified components',
      ],
    };
  }
  private generateMergeSteps(repoPath: string, branches: string[]) {
    return [
      'Create backup branches',
      'Create integration branch from recommended base',
      'Cherry-pick non-conflicting changes',
      'Resolve conflicts in hotspots',
      'Run test suite after each significant change',
      'Verify functionality of modified components',
      'Update documentation to reflect changes',
    ];
  }
  private generateOverviewSummary(overview: Array<{ branch: string; commitCount: number }>) {
    const totalCommits = overview.reduce((sum, { commitCount }) => sum + commitCount, 0);
    const avgCommits = totalCommits / overview.length;
    return {
      totalBranches: overview.length,
      totalCommits,
      averageCommitsPerBranch: Math.round(avgCommits),
      mostActiveBranch: overview.reduce((a, b) => 
        a.commitCount > b.commitCount ? a : b
      ).branch,
    };
  }
  private generateTimePeriodSummary(
    analysis: Array<{
      branch: string;
      commits: Array<{ message: string }>;
      activitySummary: { totalCommits: number };
    }>
  ) {
    const totalCommits = analysis.reduce(
      (sum, { activitySummary }) => sum + activitySummary.totalCommits,
      0
    );
    return {
      totalCommits,
      branchesWithActivity: analysis.filter(
        ({ activitySummary }) => activitySummary.totalCommits > 0
      ).length,
      mostActiveBy: {
        commits: analysis.reduce((a, b) =>
          a.activitySummary.totalCommits > b.activitySummary.totalCommits ? a : b
        ).branch,
      },
    };
  }
  private generateFileChangesSummary(
    analysis: Array<{
      file: string;
      changes: Array<{ branch: string; history: Array<unknown> }>;
      conflicts: { riskLevel: string };
    }>
  ) {
    const riskLevels = analysis.map(({ conflicts }) => conflicts.riskLevel);
    
    return {
      totalFiles: analysis.length,
      filesWithConflicts: riskLevels.filter(level => level !== 'low').length,
      highRiskFiles: riskLevels.filter(level => level === 'high').length,
      recommendedReviewOrder: analysis
        .sort((a, b) => this.riskToNumber(b.conflicts.riskLevel) - this.riskToNumber(a.conflicts.riskLevel))
        .map(({ file }) => file),
    };
  }
  private riskToNumber(risk: string): number {
    switch (risk) {
      case 'high': return 3;
      case 'medium': return 2;
      case 'low': return 1;
      default: return 0;
    }
  }
  async run() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error('Git Analysis MCP server running on stdio');
  }
}
const server = new GitAnalysisServer();
server.run().catch(console.error);