UNPKG

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
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('&#10;'); 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