git-contributor-stats
Version:
CLI to compute contributor and repository statistics from a Git repository (commits, lines added/deleted, frequency, heatmap, bus-factor), with filters and multiple output formats.
492 lines (482 loc) • 18.4 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import { g as getMetricValue, e as ensureDir, d as parseTopStatsMetrics } from "../chunks/utils-CFufYn8A.mjs";
function escapeCSV(v) {
if (v === null || v === void 0) return "";
const s = String(v);
if (s.includes(",") || s.includes('"') || s.includes("\n")) {
return `"${s.replaceAll('"', '""')}"`;
}
return s;
}
function toCSV(rows, headers) {
const lines = [];
if (headers)
lines.push(headers.map((header) => header.charAt(0).toUpperCase() + header.slice(1)).join(","));
for (const r of rows) {
lines.push(headers.map((h) => escapeCSV(r[h])).join(","));
}
return lines.join("\n");
}
function generateCSVReport(analysis) {
const contribRows = analysis.topContributors.map((c) => ({
contributor: `${c.name || "Unknown"} <${c.email || ""}>`,
commits: c.commits,
added: c.added,
deleted: c.deleted,
net: c.added - c.deleted,
topFiles: c.topFiles ? c.topFiles.slice(0, 5).map((f) => `${f.filename} (${f.changes})`).join("; ") : ""
}));
return toCSV(contribRows, ["contributor", "commits", "added", "deleted", "net", "topFiles"]);
}
function generateHTMLReport(data, repoRoot, opts = {}) {
const includeTopStats = opts.includeTopStats !== false;
const topMetrics = Array.isArray(opts.topStatsMetrics) ? opts.topStatsMetrics : ["commits", "additions", "deletions", "net", "changes"];
const topStatsHTML = includeTopStats ? generateTopStatsHTML(data.topStats, topMetrics) : "";
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Git Contributor Stats - ${repoRoot}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${getCSS()}
</style>
</head>
<body>
<header class="header">
<h1>📊 Git Contributor Stats</h1>
<p class="repo-path"><strong>Repository:</strong> <code>${repoRoot}</code></p>
<p class="generated-time">Generated: ${(/* @__PURE__ */ new Date()).toLocaleString()}</p>
</header>
<main class="main">
${generateSummaryHTML(data)}
${topStatsHTML}
${generateContributorsHTML(data)}
${generateChartsHTML()}
${generateBusFactorHTML(data)}
${generateActivityHTML()}
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js"><\/script>
<script>
${getJavaScript(data)}
<\/script>
</body>
</html>`;
}
function getCSS() {
return `
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6; margin: 0; background: #f5f7fa; color: #2d3748;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; padding: 2rem; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.header h1 { margin: 0; font-size: 2.5rem; font-weight: 300; }
.repo-path, .generated-time { opacity: 0.9; margin: 0.5rem 0; }
.main { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
.section {
background: white; margin: 2rem 0; padding: 2rem; border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1); border: 1px solid #e2e8f0;
}
.section h2 { margin-top: 0; color: #2d3748; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
.stat-card { background: #f7fafc; padding: 1.5rem; border-radius: 8px; border-left: 4px solid #4299e1; }
.stat-value { font-size: 2rem; font-weight: bold; color: #2d3748; }
.stat-label { color: #718096; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.5px; }
.contributors-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.contributors-table th, .contributors-table td {
padding: 0.75rem; text-align: left; border-bottom: 1px solid #e2e8f0;
}
.contributors-table th { background: #f7fafc; font-weight: 600; color: #4a5568; }
.contributors-table tr:hover { background: #f7fafc; }
.chart-container { max-width: 100%; margin: 2rem 0; }
.chart-container canvas { max-height: 400px; }
.heatmap-table { border-collapse: collapse; margin: 1rem auto; }
.heatmap-table th, .heatmap-table td {
width: 30px; height: 25px; text-align: center; font-size: 10px;
border: 1px solid #e2e8f0; padding: 2px;
}
.heatmap-table th { background: #f7fafc; font-weight: 600; }
.heatmap-table td { position: relative; transition: transform 0.2s; }
.heatmap-table td:hover { transform: scale(1.2); z-index: 10; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
.heatmap-table td[data-tooltip]:not([data-tooltip=""]):hover { cursor: pointer; }
.heatmap-table td[data-tooltip]:not([data-tooltip=""]):hover::after {
content: attr(data-tooltip);
position: absolute;
cursor: default;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #2d3748;
color: white;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 12px;
white-space: pre;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
margin-bottom: 5px;
}
.heatmap-table td[data-tooltip]:not([data-tooltip=""]):hover::before {
content: '';
position: absolute;
cursor: pointer;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #2d3748;
margin-bottom: -5px;
}
.top-stats { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; }
.top-stats h2 { color: white; border-color: rgba(255,255,255,0.3); }
.top-stat-item { margin: 0.5rem 0; font-size: 1.1rem; }
code { background: rgba(0,0,0,0.1); padding: 0.2rem 0.4rem; border-radius: 4px; }
`;
}
function generateSummaryHTML(data) {
return `
<section class="section">
<h2>📈 Repository Summary</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${Object.keys(data.contributors).length.toLocaleString()}</div>
<div class="stat-label">Contributors</div>
</div>
<div class="stat-card">
<div class="stat-value">${data.totalCommits.toLocaleString()}</div>
<div class="stat-label">Total Commits</div>
</div>
<div class="stat-card">
<div class="stat-value">${data.totalLines.toLocaleString()}</div>
<div class="stat-label">Lines of Code</div>
</div>
<div class="stat-card">
<div class="stat-value">${data.busFactor.filesSingleOwner.length}</div>
<div class="stat-label">Single-Owner Files</div>
</div>
</div>
</section>
`;
}
function generateTopStatsHTML(topStats, metrics) {
if (!topStats) return "";
const want = new Set(metrics);
const items = [];
if (want.has("commits") && topStats.byCommits) {
items.push(
`<div class="top-stat-item">🏆 <strong>Most Commits:</strong> ${topStats.byCommits.name} (${topStats.byCommits.commits})</div>`
);
}
if (want.has("additions") && topStats.byAdditions) {
items.push(
`<div class="top-stat-item">➕ <strong>Most Additions:</strong> ${topStats.byAdditions.name} (${topStats.byAdditions.added?.toLocaleString()})</div>`
);
}
if (want.has("deletions") && topStats.byDeletions) {
items.push(
`<div class="top-stat-item">➖ <strong>Most Deletions:</strong> ${topStats.byDeletions.name} (${topStats.byDeletions.deleted?.toLocaleString()})</div>`
);
}
if (want.has("net") && topStats.byNet) {
items.push(
`<div class="top-stat-item">📊 <strong>Best Net Contribution:</strong> ${topStats.byNet.name} (${topStats.byNet.net?.toLocaleString()})</div>`
);
}
return `
<section class="section top-stats">
<h2>🎯 Top Statistics</h2>
${items.join("")}
</section>
`;
}
function generateContributorsHTML(data) {
const rows = data.topContributors.slice(0, 25).map((c, idx) => {
const net = (c.added || 0) - (c.deleted || 0);
return `
<tr>
<td>${idx + 1}</td>
<td><strong>${c.name}</strong><br><code>${c.email}</code></td>
<td>${(c.commits || 0).toLocaleString()}</td>
<td style="color: #38a169">${(c.added || 0).toLocaleString()}</td>
<td style="color: #e53e3e">${(c.deleted || 0).toLocaleString()}</td>
<td style="color: ${net >= 0 ? "#38a169" : "#e53e3e"}">${net.toLocaleString()}</td>
</tr>
`;
}).join("");
return `
<section class="section">
<h2>👥 Top Contributors</h2>
<table class="contributors-table">
<thead>
<tr>
<th>Rank</th>
<th>Contributor</th>
<th>Commits</th>
<th>Added</th>
<th>Deleted</th>
<th>Net</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</section>
`;
}
function generateChartsHTML() {
return `
<section class="section">
<h2>📊 Contribution Charts</h2>
<div class="chart-container">
<h3>Commits by Contributor</h3>
<canvas id="commitsChart"></canvas>
</div>
<div class="chart-container">
<h3>Net Lines by Contributor</h3>
<canvas id="netChart"></canvas>
</div>
</section>
`;
}
function generateBusFactorHTML(data) {
const files = data.busFactor.filesSingleOwner.slice(0, 10).map(
(f) => `<tr><td><code>${f.file}</code></td><td>${f.owner}</td><td>${f.changes.toLocaleString()}</td></tr>`
).join("");
return `
<section class="section">
<h2>⚠️ Bus Factor Analysis</h2>
<p>Files with only one contributor (high risk):</p>
<table class="contributors-table">
<thead><tr><th>File</th><th>Owner</th><th>Changes</th></tr></thead>
<tbody>${files}</tbody>
</table>
</section>
`;
}
function generateActivityHTML() {
return `
<section class="section">
<h2>🕒 Activity Heatmap</h2>
<p>Commit activity by day of week and hour:</p>
<table class="heatmap-table" id="heatmap"></table>
</section>
`;
}
function getJavaScript(data) {
return `
const topContributors = ${JSON.stringify(data.topContributors.slice(0, 15))};
const heatmap = ${JSON.stringify(data.heatmap)};
const heatmapContributors = ${JSON.stringify(data.heatmapContributors || {})};
// Commits chart
new Chart(document.getElementById('commitsChart'), {
type: 'bar',
data: {
labels: topContributors.map(c => c.name),
datasets: [{
label: 'Commits',
data: topContributors.map(c => c.commits),
backgroundColor: '#4299e1'
}]
},
options: { responsive: true, plugins: { legend: { display: false } } }
});
// Net lines chart
new Chart(document.getElementById('netChart'), {
type: 'bar',
data: {
labels: topContributors.map(c => c.name),
datasets: [{
label: 'Net Lines',
data: topContributors.map(c => (c.added || 0) - (c.deleted || 0)),
backgroundColor: '#48bb78'
}]
},
options: { responsive: true, plugins: { legend: { display: false } } }
});
// Heatmap
const dayAbbr = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const max = Math.max(...heatmap.flat());
let html = '<tr><th></th>' + Array.from({length:24}, (_, i) => '<th>' + i + '</th>').join('') + '</tr>';
for (let day = 0; day < 7; day++) {
html += '<tr><th>' + dayAbbr[day] + '</th>';
for (let hour = 0; hour < 24; hour++) {
const val = heatmap[day][hour] || 0;
const intensity = max ? (val / max) * 0.8 : 0;
const color = 'rgba(66, 153, 225, ' + intensity + ')';
// Get top contributors for this time slot
const key = day + '-' + hour;
const contributors = heatmapContributors[key] || {};
const topContribs = Object.entries(contributors)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([name, count]) => name + ' (' + count + ')')
.join(' ');
html += '<td style="background:' + color + '" data-tooltip="' + topContribs + '">' + (val || '') + '</td>';
}
html += '</tr>';
}
document.getElementById('heatmap').innerHTML = html;
`;
}
function formatStatLine(label, entry, metricKey) {
if (!entry) return `- **${label}:** —`;
const metricVal = getMetricValue(entry, metricKey);
const suffix = typeof metricVal === "number" ? ` (${metricVal.toLocaleString()})` : "";
const emailPart = entry.email ? ` <${entry.email}>` : "";
const who = `${entry.name || "—"}${emailPart}`;
return `- **${label}:** ${who}${suffix}`;
}
function addTopStatsSection(lines, topStats, topMetrics) {
const want = new Set(topMetrics);
lines.push("## Top stats", "");
if (want.has("commits"))
lines.push(formatStatLine("Most commits", topStats.byCommits, "commits"));
if (want.has("additions"))
lines.push(formatStatLine("Most additions", topStats.byAdditions, "added"));
if (want.has("deletions"))
lines.push(formatStatLine("Most deletions", topStats.byDeletions, "deleted"));
if (want.has("net")) lines.push(formatStatLine("Best net contribution", topStats.byNet, "net"));
if (want.has("changes"))
lines.push(formatStatLine("Most changes", topStats.byChanges, "changes"));
lines.push("");
}
function addTopContributorsSection(lines, contributors) {
lines.push(
"## Top contributors",
"",
"| Rank | Contributor | Commits | Added | Deleted | Net | Top Files |",
"|---:|---|---:|---:|---:|---:|---|"
);
const topContributorsList = contributors.slice(0, 50);
for (let idx = 0; idx < topContributorsList.length; idx++) {
const c = topContributorsList[idx];
const net = (c.added || 0) - (c.deleted || 0);
const topFiles = (c.topFiles || []).slice(0, 3).map((f) => `\`${f.filename}\`(${f.changes})`).join(", ");
lines.push(
`| ${idx + 1} | **${c.name}** \`<${c.email}>\` | ${(c.commits || 0).toLocaleString()} | ${(c.added || 0).toLocaleString()} | ${(c.deleted || 0).toLocaleString()} | ${net.toLocaleString()} | ${topFiles} |`
);
}
lines.push("");
}
function addBusFactorSection(lines, busFactor) {
lines.push(
"## Bus Factor Analysis",
"",
`**Files with single contributor:** ${busFactor.filesSingleOwner.length}`,
""
);
if (busFactor.filesSingleOwner.length > 0) {
lines.push(
"### High-Risk Files (Single Owner)",
"",
"| File | Owner | Changes |",
"|---|---|---:|"
);
for (const f of busFactor.filesSingleOwner.slice(0, 20)) {
lines.push(`| \`${f.file}\` | ${f.owner} | ${f.changes.toLocaleString()} |`);
}
lines.push("");
}
}
function addActivityPatternsSection(lines, commitFrequency, heatmap) {
lines.push("## Activity Patterns", "");
const monthlyEntries = Object.entries(commitFrequency.monthly).sort(([a], [b]) => a.localeCompare(b)).slice(-12);
if (monthlyEntries.length > 0) {
lines.push("### Recent Monthly Activity", "", "| Month | Commits |", "|---|---:|");
for (const [month, commits] of monthlyEntries) {
lines.push(`| ${month} | ${commits.toLocaleString()} |`);
}
lines.push("");
}
lines.push(
"### Commit Heatmap Data",
"",
"> Commit activity by day of week (0=Sunday) and hour (0-23)",
"",
"```json",
JSON.stringify(heatmap, null, 2),
"```"
);
}
function generateMarkdownReport(data, repoRoot, opts = {}) {
const includeTopStats = opts.includeTopStats !== false;
const topMetrics = Array.isArray(opts.topStatsMetrics) ? opts.topStatsMetrics : ["commits", "additions", "deletions", "net", "changes"];
const lines = [];
lines.push(
"# Git Contributor Stats",
"",
`**Repository:** ${repoRoot}`,
`**Generated:** ${(/* @__PURE__ */ new Date()).toISOString()}`,
"",
"## Summary",
"",
`- **Total contributors:** ${Object.keys(data.contributors).length}`,
`- **Total commits:** ${data.totalCommits.toLocaleString()}`,
`- **Total lines:** ${data.totalLines.toLocaleString()}`,
""
);
if (includeTopStats && data.topStats) {
addTopStatsSection(lines, data.topStats, topMetrics);
}
addTopContributorsSection(lines, data.topContributors);
addBusFactorSection(lines, data.busFactor);
addActivityPatternsSection(lines, data.commitFrequency, data.heatmap);
return lines.join("\n");
}
function toReportContributor(tc) {
return {
name: tc.name ?? "",
email: tc.email ? tc.email : "",
commits: tc.commits,
added: tc.added,
deleted: tc.deleted,
topFiles: tc.topFiles ?? []
};
}
async function generateReports(final, opts = {}) {
const outDir = opts.outDir;
const writeCSVPath = opts.csv || (outDir ? path.join(outDir, "contributors.csv") : void 0);
const writeMDPath = opts.md || (outDir ? path.join(outDir, "report.md") : void 0);
const writeHTMLPath = opts.html || (outDir ? path.join(outDir, "report.html") : void 0);
if (writeCSVPath) {
ensureDir(path.dirname(writeCSVPath));
const csv = generateCSVReport({ topContributors: final.topContributors });
fs.writeFileSync(writeCSVPath, csv, "utf8");
console.error(`Wrote CSV to ${writeCSVPath}`);
}
const topStatsMetrics = parseTopStatsMetrics(opts.topStats);
const analysisData = writeMDPath || writeHTMLPath ? {
...final,
topContributors: final.topContributors.map(toReportContributor),
busFactor: {
...final.busFactor,
filesSingleOwner: final.busFactor.filesSingleOwner ?? []
}
} : null;
if (writeMDPath && analysisData) {
ensureDir(path.dirname(writeMDPath));
const md = generateMarkdownReport(analysisData, final.meta.repo, {
includeTopStats: !!opts.topStats,
topStatsMetrics
});
fs.writeFileSync(writeMDPath, md, "utf8");
console.error(`Wrote Markdown report to ${writeMDPath}`);
}
if (writeHTMLPath && analysisData) {
ensureDir(path.dirname(writeHTMLPath));
const html = generateHTMLReport(analysisData, final.meta.repo, {
includeTopStats: !!opts.topStats,
topStatsMetrics
});
fs.writeFileSync(writeHTMLPath, html, "utf8");
console.error(`Wrote HTML report to ${writeHTMLPath}`);
}
}
export {
generateReports as g
};
//# sourceMappingURL=reports.mjs.map