Skip to main content
Glama
MUSE-CODE-SPACE

Vibe Coding Documentation MCP (MUSE)

git.js19.4 kB
/** * Git Integration Tool (muse_git) * Provides git repository context for vibe coding sessions */ import { ToolError } from '../core/errors.js'; import { createToolLogger } from '../core/logger.js'; import { execGit, getRemoteUrl, validateRepoPath, } from '../utils/gitExecutor.js'; import { parseStatusPorcelainV2, parseLogOutput, parseDiffStat, parseBranchOutput, parseStashList, detectLanguage, inferCategory, calculateCommitImportance, } from '../utils/gitParsers.js'; import { getSession, updateSession } from '../core/sessionStorage.js'; const logger = createToolLogger('git'); // Action implementations async function getStatus(repoPath, includeUntracked) { const args = ['status', '--porcelain=v2', '--branch']; if (includeUntracked) { args.push('--untracked-files=all'); } else { args.push('--untracked-files=no'); } const result = await execGit(args, { cwd: repoPath }); if (result.exitCode !== 0) { throw new ToolError(`Git status failed: ${result.stderr}`, 'INTERNAL_ERROR'); } const parsed = parseStatusPorcelainV2(result.stdout); return { branch: parsed.branch, upstream: parsed.upstream, ahead: parsed.ahead, behind: parsed.behind, staged: parsed.staged, unstaged: parsed.unstaged, untracked: parsed.untracked, conflicts: parsed.conflicts, isClean: parsed.staged.length === 0 && parsed.unstaged.length === 0 && parsed.conflicts.length === 0, isDetached: parsed.branch === '(detached)', }; } async function getLog(repoPath, options) { const { limit = 20, author, since, until, grep, oneline = false, path } = options; const format = oneline ? '%H|%h|%an|%ae|%aI|%s' : '%H|%h|%an|%ae|%aI|%s|%b|END_COMMIT'; const args = ['log', `--format=${format}`, `-n${limit}`, '--no-merges']; if (author) args.push(`--author=${author}`); if (since) args.push(`--since=${since}`); if (until) args.push(`--until=${until}`); if (grep) args.push(`--grep=${grep}`, '-i'); if (path) args.push('--', path); const result = await execGit(args, { cwd: repoPath }); if (result.exitCode !== 0) { throw new ToolError(`Git log failed: ${result.stderr}`, 'INTERNAL_ERROR'); } return parseLogOutput(result.stdout, !oneline); } async function getDiff(repoPath, options) { const { diffType = 'all', fromRef, toRef, path, contextLines = 3, stat = true } = options; const args = ['diff']; if (fromRef && toRef) { args.push(fromRef, toRef); } else if (fromRef) { args.push(fromRef); } else if (diffType === 'staged') { args.push('--cached'); } else if (diffType === 'all') { args.push('HEAD'); } // unstaged is default (no extra args) args.push(`-U${contextLines}`); if (stat) { args.push('--stat'); } if (path) { args.push('--', path); } const result = await execGit(args, { cwd: repoPath }); // diff returns exit code 1 if there are differences, which is not an error if (result.exitCode !== 0 && result.exitCode !== 1) { throw new ToolError(`Git diff failed: ${result.stderr}`, 'INTERNAL_ERROR'); } const parsed = parseDiffStat(result.stdout); return { files: parsed.files, totalAdditions: parsed.totalAdditions, totalDeletions: parsed.totalDeletions, patch: stat ? undefined : result.stdout, }; } async function getBranches(repoPath, options) { const { includeRemote = true, verbose = false } = options; // Get current branch const currentResult = await execGit(['branch', '--show-current'], { cwd: repoPath }); const current = currentResult.stdout.trim() || 'HEAD'; const isDetached = current === 'HEAD' || current === ''; // Get local branches const localFormat = '%(refname:short)|%(objectname:short)|%(upstream:short)|%(upstream:track,nobracket)'; const localResult = await execGit(['branch', `--format=${localFormat}`], { cwd: repoPath }); const local = parseBranchOutput(localResult.stdout, current); // Get remote branches let remote = []; if (includeRemote) { const remoteResult = await execGit(['branch', '-r', '--format=%(refname:short)|%(objectname:short)'], { cwd: repoPath }); if (remoteResult.exitCode === 0) { remote = remoteResult.stdout .split('\n') .filter((l) => l.trim()) .map((line) => { const [name, lastCommit] = line.split('|'); return { name: name.trim(), lastCommit: lastCommit?.trim() }; }); } } return { current: isDetached ? `HEAD (detached)` : current, isDetached, local, remote, }; } async function captureSnapshot(repoPath, options) { const { includeDiff = true, includeLog = true, logLimit = 10, includeStash = false } = options; const remoteUrl = await getRemoteUrl(repoPath); const status = await getStatus(repoPath, true); const branch = await getBranches(repoPath, { includeRemote: true }); let recentCommits; if (includeLog) { recentCommits = await getLog(repoPath, { limit: logLimit }); } let currentDiff; if (includeDiff && !status.isClean) { currentDiff = await getDiff(repoPath, { diffType: 'all', stat: true }); } let stashes; if (includeStash) { const stashResult = await execGit(['stash', 'list', '--format=%gd|%s|%ci'], { cwd: repoPath }); if (stashResult.exitCode === 0 && stashResult.stdout.trim()) { stashes = parseStashList(stashResult.stdout); } } return { timestamp: new Date().toISOString(), repository: { path: repoPath, remoteUrl: remoteUrl || undefined, }, status, branch, recentCommits, currentDiff, stashes, }; } async function extractDecisions(repoPath, options) { const { limit = 50, since, author, path, patterns, language = 'auto' } = options; // Default patterns for detecting design decisions const defaultPatterns = { en: [ /(?:refactor|redesign|migrate|switch(?:ed)? to|implement|introduce)/i, /(?:architecture|design decision|tech debt)/i, /(?:breaking change|major change)/i, /\b(?:why|because|reason|rationale):/i, /feat:|fix:|refactor:|perf:|BREAKING CHANGE/i, ], ko: [ /(?:리팩토링|재설계|마이그레이션|전환|도입)/, /(?:아키텍처|설계 결정|기술 부채)/, /\b(?:이유|배경|근거):/, ], }; const commits = await getLog(repoPath, { limit, since, author, path }); const decisions = []; for (const commit of commits) { const fullMessage = `${commit.message}\n${commit.body || ''}`; const lang = language === 'auto' ? detectLanguage(fullMessage) : language; const patternsToUse = patterns ? patterns.map((p) => new RegExp(p, 'i')) : [...defaultPatterns.en, ...defaultPatterns.ko]; if (patternsToUse.some((p) => p.test(fullMessage))) { // Get files changed in this commit const filesResult = await execGit(['diff-tree', '--no-commit-id', '--name-only', '-r', commit.hash], { cwd: repoPath }); const relatedFiles = filesResult.exitCode === 0 ? filesResult.stdout .split('\n') .filter((f) => f.trim()) .slice(0, 10) : []; decisions.push({ commitHash: commit.hash, shortHash: commit.shortHash, date: commit.date, author: commit.author, title: commit.message.split('\n')[0], description: commit.body || commit.message, category: inferCategory(fullMessage, lang), relatedFiles, importance: calculateCommitImportance(commit, fullMessage), }); } } return decisions; } async function linkToSession(sessionId, repoPath, snapshotType) { const session = await getSession(sessionId); if (!session) { throw new ToolError(`Session not found: ${sessionId}`, 'NOT_FOUND', { sessionId }); } const snapshot = await captureSnapshot(repoPath, { includeDiff: snapshotType === 'full', includeLog: snapshotType === 'full', logLimit: snapshotType === 'full' ? 10 : 3, includeStash: false, }); const gitContext = snapshotType === 'full' ? snapshot : { linkedAt: new Date().toISOString(), repository: snapshot.repository, branch: snapshot.branch.current, commitHash: snapshot.recentCommits?.[0]?.hash, isClean: snapshot.status.isClean, modifiedCount: snapshot.status.unstaged.length + snapshot.status.staged.length, }; await updateSession(sessionId, { metadata: { ...(session.metadata || {}), gitContext, }, }); } // Main tool function export async function gitTool(input) { const startTime = Date.now(); const { action } = input; logger.info(`Git ${action} requested`, { action, repoPath: input.repoPath }); try { const repoPath = await validateRepoPath(input.repoPath); switch (action) { case 'status': { const status = await getStatus(repoPath, input.includeUntracked ?? true); return { success: true, action, status, repoPath, executionTime: Date.now() - startTime, message: status.isClean ? 'Working tree clean' : 'Changes detected', }; } case 'log': { const commits = await getLog(repoPath, { limit: input.limit, author: input.author, since: input.since, until: input.until, grep: input.grep, oneline: input.oneline, path: input.path, }); return { success: true, action, commits, repoPath, executionTime: Date.now() - startTime, message: `Found ${commits.length} commits`, }; } case 'diff': { const diff = await getDiff(repoPath, { diffType: input.diffType, fromRef: input.fromRef, toRef: input.toRef, path: input.path, contextLines: input.contextLines, stat: input.stat, }); return { success: true, action, diff, repoPath, executionTime: Date.now() - startTime, message: `${diff.files.length} files changed, +${diff.totalAdditions} -${diff.totalDeletions}`, }; } case 'branch': { const branch = await getBranches(repoPath, { includeRemote: input.includeRemote, verbose: input.verbose, }); return { success: true, action, branch, repoPath, executionTime: Date.now() - startTime, message: `Current branch: ${branch.current}`, }; } case 'snapshot': { const snapshot = await captureSnapshot(repoPath, { includeDiff: input.includeDiff, includeLog: input.includeLog, logLimit: input.logLimit, includeStash: input.includeStash, }); return { success: true, action, snapshot, repoPath, executionTime: Date.now() - startTime, message: `Snapshot captured for ${snapshot.branch.current}`, }; } case 'extractDecisions': { const decisions = await extractDecisions(repoPath, { limit: input.limit, since: input.since, author: input.author, path: input.path, patterns: input.patterns, language: input.language, }); return { success: true, action, decisions, repoPath, executionTime: Date.now() - startTime, message: `Extracted ${decisions.length} design decisions`, }; } case 'linkToSession': { if (!input.sessionId) { throw new ToolError('sessionId is required for linkToSession action', 'VALIDATION_ERROR'); } await linkToSession(input.sessionId, repoPath, input.snapshotType ?? 'minimal'); return { success: true, action, linkedSessionId: input.sessionId, repoPath, executionTime: Date.now() - startTime, message: `Git context linked to session ${input.sessionId}`, }; } default: throw new ToolError(`Unknown action: ${action}`, 'VALIDATION_ERROR'); } } catch (error) { logger.error(`Git ${action} failed`, error); const errorMessage = error instanceof Error ? error.message : String(error); const isNotRepo = errorMessage.includes('Not a git repository'); const isNotInstalled = errorMessage.includes('git: command not found'); return { success: false, action, repoPath: input.repoPath, executionTime: Date.now() - startTime, error: isNotRepo ? 'Not a git repository. Initialize with "git init" or specify a valid repository path.' : isNotInstalled ? 'Git is not installed or not in PATH.' : errorMessage, }; } } // MCP Schema export const gitSchema = { name: 'muse_git', description: 'Git integration for vibe coding sessions. Get repository status, commit history, diffs, branch info. Capture git snapshots for sessions and extract design decisions from commit messages.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['status', 'log', 'diff', 'branch', 'snapshot', 'extractDecisions', 'linkToSession'], description: 'Action: status (repo state), log (commit history), diff (changes), branch (branch info), snapshot (full context), extractDecisions (from commits), linkToSession (attach to session)', }, repoPath: { type: 'string', description: 'Path to git repository. Defaults to current working directory.', }, includeUntracked: { type: 'boolean', description: 'Include untracked files in status (default: true)', }, limit: { type: 'number', description: 'Max commits to return for log/extractDecisions (default: 20, max: 500)', }, author: { type: 'string', description: 'Filter commits by author name or email', }, since: { type: 'string', description: 'Filter commits after date (e.g., "2024-01-01", "1 week ago")', }, until: { type: 'string', description: 'Filter commits before date', }, grep: { type: 'string', description: 'Search commit messages for keyword', }, oneline: { type: 'boolean', description: 'Compact log format (default: false)', }, diffType: { type: 'string', enum: ['staged', 'unstaged', 'all'], description: 'Diff type: staged, unstaged, or all changes (default: all)', }, fromRef: { type: 'string', description: 'Source commit/branch/tag for diff', }, toRef: { type: 'string', description: 'Target commit/branch/tag for diff', }, path: { type: 'string', description: 'Filter by file or directory path', }, contextLines: { type: 'number', description: 'Lines of context around changes (default: 3)', }, stat: { type: 'boolean', description: 'Include stat summary in diff (default: true)', }, includeRemote: { type: 'boolean', description: 'Include remote branches (default: true)', }, verbose: { type: 'boolean', description: 'Include last commit info per branch (default: false)', }, includeDiff: { type: 'boolean', description: 'Include current diff in snapshot (default: true)', }, includeLog: { type: 'boolean', description: 'Include recent commits in snapshot (default: true)', }, logLimit: { type: 'number', description: 'Commits to include in snapshot (default: 10)', }, includeStash: { type: 'boolean', description: 'Include stash list in snapshot (default: false)', }, patterns: { type: 'array', items: { type: 'string' }, description: 'Custom regex patterns for detecting design decisions', }, language: { type: 'string', enum: ['en', 'ko', 'auto'], description: 'Language for analysis (default: auto-detect)', }, sessionId: { type: 'string', description: 'Session ID to link git context to (required for linkToSession)', }, snapshotType: { type: 'string', enum: ['minimal', 'full'], description: 'Detail level when linking to session (default: minimal)', }, }, required: ['action'], }, }; //# sourceMappingURL=git.js.map

Latest Blog Posts

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/MUSE-CODE-SPACE/vibe-coding-mcp'

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