@jonmatum/git-metrics-mcp-server
Version:
MCP server for analyzing git repository metrics and understanding team health
467 lines (466 loc) • 17.9 kB
JavaScript
import { runGitCommand, validateRepoPath, validateDate } from './git-metrics.js';
export function handleGetCommitStats(args) {
const { repo_path, since, until, author } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
if (author)
cmd += ` --author="${author}"`;
cmd += ` --pretty=format:"%H" --shortstat`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n");
let commits = 0, additions = 0, deletions = 0, filesChanged = 0;
for (const line of lines) {
if (line.match(/^[0-9a-f]{40}$/)) {
commits++;
}
else if (line.includes("changed")) {
const addMatch = line.match(/(\d+) insertion/);
const delMatch = line.match(/(\d+) deletion/);
const fileMatch = line.match(/(\d+) file/);
if (addMatch)
additions += parseInt(addMatch[1]);
if (delMatch)
deletions += parseInt(delMatch[1]);
if (fileMatch)
filesChanged += parseInt(fileMatch[1]);
}
}
return {
commits,
additions,
deletions,
filesChanged,
netChange: additions - deletions,
};
}
export function handleGetAuthorMetrics(args) {
const { repo_path, since, until } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --pretty=format:"%an <%ae>" --numstat`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n");
const authors = {};
let currentAuthor = "";
for (const line of lines) {
if (line.includes("<") && line.includes(">")) {
currentAuthor = line;
if (!authors[currentAuthor]) {
authors[currentAuthor] = { commits: 0, additions: 0, deletions: 0, files: 0 };
}
authors[currentAuthor].commits++;
}
else if (line.match(/^\d+\s+\d+/) && currentAuthor) {
const [add, del] = line.split(/\s+/);
authors[currentAuthor].additions += parseInt(add) || 0;
authors[currentAuthor].deletions += parseInt(del) || 0;
authors[currentAuthor].files++;
}
}
return authors;
}
export function handleGetFileChurn(args) {
const { repo_path, since, until, limit = 10 } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --name-only --pretty=format:`;
const output = runGitCommand(repo_path, cmd);
const fileChanges = {};
const lines = output.trim().split("\n");
for (const line of lines) {
if (line.trim()) {
fileChanges[line] = (fileChanges[line] || 0) + 1;
}
}
return Object.entries(fileChanges)
.sort(([, a], [, b]) => b - a)
.slice(0, limit)
.map(([file, changes]) => ({ file, changes }));
}
export function handleGetTeamSummary(args) {
const { repo_path, since, until } = args;
const stats = handleGetCommitStats({ repo_path, since, until });
const authors = handleGetAuthorMetrics({ repo_path, since, until });
return {
period: { since, until: until || "now" },
team: {
totalCommits: stats.commits,
totalAdditions: stats.additions,
totalDeletions: stats.deletions,
contributors: Object.keys(authors).length,
},
contributors: authors,
};
}
export function handleGetCommitPatterns(args) {
const { repo_path, since, until } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --pretty=format:"%ad" --date=format:"%u %H"`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n");
const byDay = {};
const byHour = {};
let weekendCommits = 0, lateNightCommits = 0;
for (const line of lines) {
const [day, hour] = line.split(" ");
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const dayName = dayNames[parseInt(day) - 1];
byDay[dayName] = (byDay[dayName] || 0) + 1;
byHour[hour] = (byHour[hour] || 0) + 1;
if (parseInt(day) >= 6)
weekendCommits++;
if (parseInt(hour) >= 22 || parseInt(hour) <= 6)
lateNightCommits++;
}
return {
byDay,
byHour,
patterns: {
weekendPercentage: `${((weekendCommits / lines.length) * 100).toFixed(1)}%`,
lateNightPercentage: `${((lateNightCommits / lines.length) * 100).toFixed(1)}%`,
},
};
}
export function handleGetCodeOwnership(args) {
const { repo_path, since, until } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --pretty=format:"%an <%ae>" --name-only`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n");
const fileAuthors = {};
let currentAuthor = "";
for (const line of lines) {
if (line.includes("<") && line.includes(">")) {
currentAuthor = line;
}
else if (line.trim() && currentAuthor) {
if (!fileAuthors[line])
fileAuthors[line] = new Set();
fileAuthors[line].add(currentAuthor);
}
}
const authorFiles = {};
for (const [, authors] of Object.entries(fileAuthors)) {
if (authors.size === 1) {
const author = Array.from(authors)[0];
authorFiles[author] = (authorFiles[author] || 0) + 1;
}
}
return {
totalFiles: Object.keys(fileAuthors).length,
sharedFiles: Object.values(fileAuthors).filter(a => a.size > 1).length,
soloFiles: Object.values(fileAuthors).filter(a => a.size === 1).length,
busFactor: Object.entries(authorFiles)
.sort(([, a], [, b]) => b - a)
.map(([author, exclusiveFiles]) => ({ author, exclusiveFiles })),
};
}
export function handleGetVelocityTrends(args) {
const { repo_path, since, until, interval = "week" } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
const format = interval === "month" ? "%Y-%m" : "%Y-%W";
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --pretty=format:"%ad|%H" --date=format:"${format}" --numstat`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n");
const periods = {};
let currentPeriod = "";
for (const line of lines) {
if (line.includes("|")) {
currentPeriod = line.split("|")[0];
if (!periods[currentPeriod]) {
periods[currentPeriod] = { commits: 0, additions: 0, deletions: 0 };
}
periods[currentPeriod].commits++;
}
else if (line.match(/^\d+\s+\d+/) && currentPeriod) {
const [add, del] = line.split(/\s+/);
periods[currentPeriod].additions += parseInt(add) || 0;
periods[currentPeriod].deletions += parseInt(del) || 0;
}
}
return {
interval,
trends: Object.entries(periods).map(([period, data]) => ({ period, ...data })),
};
}
export function handleGetCollaborationMetrics(args) {
const { repo_path, since, until } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --pretty=format:"%an <%ae>" --name-only`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n");
const fileAuthors = {};
let currentAuthor = "";
for (const line of lines) {
if (line.includes("<") && line.includes(">")) {
currentAuthor = line;
}
else if (line.trim() && currentAuthor) {
if (!fileAuthors[line])
fileAuthors[line] = new Set();
fileAuthors[line].add(currentAuthor);
}
}
const collaborations = {};
for (const authors of Object.values(fileAuthors)) {
if (authors.size > 1) {
const authorList = Array.from(authors).sort();
for (let i = 0; i < authorList.length; i++) {
for (let j = i + 1; j < authorList.length; j++) {
const pair = `${authorList[i]} <-> ${authorList[j]}`;
collaborations[pair] = (collaborations[pair] || 0) + 1;
}
}
}
}
return {
collaborativeFiles: Object.values(fileAuthors).filter(a => a.size > 1).length,
topCollaborations: Object.entries(collaborations)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([pair, sharedFiles]) => ({ pair, sharedFiles })),
};
}
export function handleGetQualityMetrics(args) {
const { repo_path, since, until } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --pretty=format:"%H|%s" --numstat`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n");
const commitSizes = [];
let currentSize = 0;
let reverts = 0, fixes = 0, totalCommits = 0;
for (const line of lines) {
if (line.includes("|")) {
if (currentSize > 0)
commitSizes.push(currentSize);
currentSize = 0;
totalCommits++;
const msg = line.split("|")[1].toLowerCase();
if (msg.includes("revert"))
reverts++;
if (msg.includes("fix"))
fixes++;
}
else if (line.match(/^\d+\s+\d+/)) {
const [add, del] = line.split(/\s+/);
currentSize += (parseInt(add) || 0) + (parseInt(del) || 0);
}
}
const avg = commitSizes.reduce((a, b) => a + b, 0) / commitSizes.length || 0;
const sorted = commitSizes.sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] || 0;
return {
averageCommitSize: Math.round(avg),
medianCommitSize: median,
revertRate: `${((reverts / totalCommits) * 100).toFixed(1)}%`,
fixRate: `${((fixes / totalCommits) * 100).toFixed(1)}%`,
};
}
export function handleGetTechnicalDebt(args) {
const { repo_path, stale_days = 90 } = args;
validateRepoPath(repo_path);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - stale_days);
const cutoffTimestamp = Math.floor(cutoffDate.getTime() / 1000);
const staleCmd = `git ls-files -z | xargs -0 -n1 -I{} sh -c 'echo "{}|$(git log -1 --format=%ct -- "{}")"' | awk -F'|' '$2 < ${cutoffTimestamp} {print $1"|"$2}'`;
const churnCmd = `git log --name-only --pretty=format: | sort | uniq -c | sort -rn | head -20`;
let staleFiles = [];
try {
const staleOutput = runGitCommand(repo_path, staleCmd);
const now = Math.floor(Date.now() / 1000);
staleFiles = staleOutput.trim().split("\n")
.filter(l => l)
.map(line => {
const [file, timestamp] = line.split("|");
const days = Math.floor((now - parseInt(timestamp)) / 86400);
return { file, daysSinceLastChange: days };
})
.sort((a, b) => b.daysSinceLastChange - a.daysSinceLastChange)
.slice(0, 10);
}
catch {
// Fallback to simpler approach
const filesCmd = `git ls-files | head -100`;
const files = runGitCommand(repo_path, filesCmd).trim().split("\n");
for (const file of files) {
try {
const lastChangeCmd = `git log -1 --format=%ct -- "${file}"`;
const timestamp = parseInt(runGitCommand(repo_path, lastChangeCmd).trim());
const days = Math.floor((Date.now() / 1000 - timestamp) / 86400);
if (days > stale_days) {
staleFiles.push({ file, daysSinceLastChange: days });
}
}
catch { }
}
staleFiles = staleFiles.sort((a, b) => b.daysSinceLastChange - a.daysSinceLastChange).slice(0, 10);
}
let complexityHotspots = [];
try {
const churnOutput = runGitCommand(repo_path, churnCmd);
complexityHotspots = churnOutput.trim().split("\n")
.filter(l => l.trim())
.map(line => {
const match = line.trim().match(/^\s*(\d+)\s+(.+)$/);
if (match) {
return { file: match[2], changes: parseInt(match[1]) };
}
return null;
})
.filter(x => x !== null)
.slice(0, 10);
}
catch { }
// Get large files (>500 lines)
let largeFiles = [];
try {
const filesCmd = `git ls-files`;
const files = runGitCommand(repo_path, filesCmd).trim().split("\n").filter(f => f);
for (const file of files.slice(0, 100)) {
try {
const linesCmd = `wc -l "${file}"`;
const output = runGitCommand(repo_path, linesCmd).trim();
const lines = parseInt(output.split(/\s+/)[0]);
if (lines > 500) {
largeFiles.push({ file, lines });
}
}
catch { }
}
largeFiles = largeFiles.sort((a, b) => b.lines - a.lines).slice(0, 10);
}
catch { }
// Calculate average file age
let averageFileAge = null;
try {
const filesCmd = `git ls-files`;
const files = runGitCommand(repo_path, filesCmd).trim().split("\n").filter(f => f);
const now = Math.floor(Date.now() / 1000);
let totalAge = 0;
let count = 0;
for (const file of files.slice(0, 100)) {
try {
const lastChangeCmd = `git log -1 --format=%ct -- "${file}"`;
const timestamp = parseInt(runGitCommand(repo_path, lastChangeCmd).trim());
const days = Math.floor((now - timestamp) / 86400);
totalAge += days;
count++;
}
catch { }
}
if (count > 0) {
averageFileAge = Math.round(totalAge / count);
}
}
catch { }
return {
staleFiles,
largeFiles,
complexityHotspots,
averageFileAge,
};
}
export function handleGetConventionalCommits(args) {
const { repo_path, since, until } = args;
validateRepoPath(repo_path);
validateDate(since, "since");
if (until)
validateDate(until, "until");
let cmd = `git log --since="${since}"`;
if (until)
cmd += ` --until="${until} 23:59:59"`;
cmd += ` --pretty=format:"%H|%s|%ad" --date=short`;
const output = runGitCommand(repo_path, cmd);
const lines = output.trim().split("\n").filter(l => l);
const types = {};
const scopes = {};
let breaking = 0;
let conventional = 0;
const conventionalRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(([^)]+)\))?(!)?:/;
for (const line of lines) {
const [, message] = line.split("|");
const match = message.match(conventionalRegex);
if (match) {
conventional++;
const [, type, , scope, isBreaking] = match;
types[type] = (types[type] || 0) + 1;
if (scope)
scopes[scope] = (scopes[scope] || 0) + 1;
if (isBreaking || message.includes("BREAKING CHANGE"))
breaking++;
}
}
const tagsCmd = `git tag --sort=-creatordate --format="%(refname:short)|%(creatordate:short)"`;
const tagsOutput = runGitCommand(repo_path, tagsCmd);
const tags = tagsOutput.trim().split("\n").filter(t => t);
const releases = tags
.map(t => {
const [tag, date] = t.split("|");
return { tag, date };
})
.filter(r => {
const releaseDate = new Date(r.date);
const sinceDate = new Date(since);
const untilDate = until ? new Date(until) : new Date();
return releaseDate >= sinceDate && releaseDate <= untilDate;
});
const sortedScopes = Object.entries(scopes).sort(([, a], [, b]) => b - a);
return {
totalCommits: lines.length,
conventionalCommits: conventional,
conventionalPercentage: `${((conventional / lines.length) * 100).toFixed(1)}%`,
commitTypes: Object.entries(types).sort(([, a], [, b]) => b - a).map(([type, count]) => ({ type, count })),
topScopes: sortedScopes.slice(0, 10).map(([scope, count]) => ({ scope, count })),
totalScopeCount: sortedScopes.length,
breakingChanges: breaking,
recentReleases: releases,
totalReleasesCount: releases.length,
releaseFrequency: releases.length > 1 ? `${releases.length} releases since ${since}` : "No releases found"
};
}