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.

402 lines (401 loc) 14.8 kB
import fs from "node:fs"; import path from "node:path"; import { b as svgEscape, e as ensureDir } from "../chunks/utils-CFufYn8A.mjs"; const scriptRel = "modulepreload"; const assetsURL = function(dep) { return "/" + dep; }; const seen = {}; const __vitePreload = function preload(baseModule, deps, importerUrl) { let promise = Promise.resolve(); if (deps && deps.length > 0) { let allSettled = function(promises$2) { return Promise.all(promises$2.map((p) => Promise.resolve(p).then((value$1) => ({ status: "fulfilled", value: value$1 }), (reason) => ({ status: "rejected", reason })))); }; document.getElementsByTagName("link"); const cspNonceMeta = document.querySelector("meta[property=csp-nonce]"); const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute("nonce"); promise = allSettled(deps.map((dep) => { dep = assetsURL(dep); if (dep in seen) return; seen[dep] = true; const isCss = dep.endsWith(".css"); const cssSelector = isCss ? '[rel="stylesheet"]' : ""; if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) return; const link = document.createElement("link"); link.rel = isCss ? "stylesheet" : scriptRel; if (!isCss) link.as = "script"; link.crossOrigin = ""; link.href = dep; if (cspNonce) link.setAttribute("nonce", cspNonce); document.head.appendChild(link); if (isCss) return new Promise((res, rej) => { link.addEventListener("load", res); link.addEventListener("error", () => rej(/* @__PURE__ */ new Error(`Unable to preload CSS for ${dep}`))); }); })); } function handlePreloadError(err$2) { const e$1 = new Event("vite:preloadError", { cancelable: true }); e$1.payload = err$2; window.dispatchEvent(e$1); if (!e$1.defaultPrevented) throw err$2; } return promise.then((res) => { for (const item of res || []) { if (item.status !== "rejected") continue; handlePreloadError(item.reason); } return baseModule().catch(handlePreloadError); }); }; function generateBarChartSVG(title, labels, values, options = {}) { const maxBars = Math.min(labels.length, options.limit || 20); labels = labels.slice(0, maxBars); values = values.slice(0, maxBars); const width = options.width || 900; const height = options.height || 360; const margin = { top: 40, right: 20, bottom: 120, left: 80 }; const chartW = width - margin.left - margin.right; const chartH = height - margin.top - margin.bottom; const maxVal = Math.max(1, ...values); const denominator = Math.max(1, maxBars); const barW = chartW / denominator * 0.7; const gap = chartW / denominator * 0.3; const svg = []; svg.push( `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`, `<style>text{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;font-size:12px} .title{font-size:16px;font-weight:700}</style>`, `<rect width="100%" height="100%" fill="#fff"/>`, `<text class="title" x="${margin.left}" y="${margin.top - 12}">${svgEscape(title)}</text>`, // axes `<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + chartH}" stroke="#333"/>`, `<line x1="${margin.left}" y1="${margin.top + chartH}" x2="${margin.left + chartW}" y2="${margin.top + chartH}" stroke="#333"/>` ); for (let i = 0; i < labels.length; i++) { const lab = labels[i]; const x = margin.left + i * (barW + gap) + gap * 0.5; const h = Math.round(values[i] / maxVal * chartH); const y = margin.top + (chartH - h); svg.push( `<rect x="${x}" y="${y}" width="${barW}" height="${h}" fill="#4e79a7"/>`, `<text x="${x + barW / 2}" y="${y - 4}" text-anchor="middle">${values[i]}</text>` ); const labText = lab.length > 16 ? `${lab.slice(0, 16)}…` : lab; svg.push( `<g transform="translate(${x + barW / 2},${margin.top + chartH + 4}) rotate(45)"><text text-anchor="start">${svgEscape(labText)}</text></g>` ); } if (maxBars === 0) { svg.push( `<text x="${margin.left + chartW / 2}" y="${margin.top + chartH / 2}" text-anchor="middle" fill="#666">No data</text>` ); } for (let t = 0; t <= 4; t++) { const val = Math.round(t / 4 * maxVal); const yy = margin.top + chartH - Math.round(t / 4 * chartH); svg.push( `<line x1="${margin.left - 5}" y1="${yy}" x2="${margin.left}" y2="${yy}" stroke="#333"/>`, `<text x="${margin.left - 8}" y="${yy + 4}" text-anchor="end">${val}</text>` ); } svg.push(`</svg>`); return svg.join(""); } function generateHeatmapSVG(heatmap) { const cellW = 26, cellH = 20; const margin = { top: 28, right: 10, bottom: 10, left: 28 }; const width = margin.left + margin.right + 24 * cellW; const height = margin.top + margin.bottom + 7 * cellH; const max = Math.max(1, ...heatmap.flat()); const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const svg = []; svg.push( `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`, `<style>text{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;font-size:10px}</style>` ); for (let h = 0; h < 24; h++) { svg.push( `<text x="${margin.left + h * cellW + 6}" y="${margin.top - 10}" text-anchor="middle">${h}</text>` ); } for (let d = 0; d < 7; d++) { svg.push( `<text x="${margin.left - 6}" y="${margin.top + d * cellH + 14}" text-anchor="end">${days[d]}</text>` ); for (let h = 0; h < 24; h++) { const val = heatmap[d][h] || 0; const intensity = Math.round(val / max * 200); const fill = `rgb(${255 - intensity},255,${255 - intensity})`; svg.push( `<rect x="${margin.left + h * cellW}" y="${margin.top + d * cellH}" width="${cellW - 1}" height="${cellH - 1}" fill="${fill}"/>` ); if (val > 0) svg.push( `<text x="${margin.left + h * cellW + cellW / 2}" y="${margin.top + d * cellH + 14}" text-anchor="middle">${val}</text>` ); } } svg.push(`</svg>`); return svg.join(""); } let ChartJSNodeCanvas = null; let registerables = null; try { const [modCanvas, modChart] = await Promise.all([ __vitePreload(() => import("chartjs-node-canvas"), true ? [] : void 0), __vitePreload(() => import("chart.js"), true ? [] : void 0) ]); ChartJSNodeCanvas = modCanvas.ChartJSNodeCanvas; registerables = modChart.registerables; } catch (_error) { } function createCanvas(format, width, height) { if (!ChartJSNodeCanvas) return null; const type = format === "svg" ? "svg" : "png"; return new ChartJSNodeCanvas({ width, height, type, chartCallback: (ChartJS) => { try { if (ChartJS && registerables) ChartJS.register(...registerables); } catch (_error) { } } }); } function validateAndPrepareDirectory(filePath) { try { ensureDir(path.dirname(filePath)); return true; } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); console.error(`[error] Failed to create directory: ${message}`); return false; } } function sanitizeChartData(labels, values, title, verbose) { if (!labels || !values || labels.length === 0) { if (verbose) { console.warn(`[warn] No data for bar chart: ${title}`); } return { labels: ["No data"], values: [0] }; } return { labels, values }; } function shouldUsePNG(format) { return format !== "svg" && ChartJSNodeCanvas !== null; } async function renderSVGFallback(filePath, generator, errorContext) { try { const svg = generator(); fs.writeFileSync(filePath, svg, "utf8"); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); console.error(`[error] ${errorContext}: ${message}`); } } async function renderBarChartImage(format, title, labels, values, filePath, options = {}) { if (!validateAndPrepareDirectory(filePath)) return; const { width = 900, height = 400, verbose = false, limit = 25 } = options; const sanitized = sanitizeChartData(labels, values, title, verbose); if (!shouldUsePNG(format)) { await renderSVGFallback( filePath, () => generateBarChartSVG(title, sanitized.labels, sanitized.values, { limit }), `Failed to generate SVG chart ${filePath}` ); return; } try { const canvas = createCanvas(format, width, height); if (!canvas) { await renderSVGFallback( filePath, () => generateBarChartSVG(title, sanitized.labels, sanitized.values, { limit }), `Canvas creation failed for ${filePath}` ); return; } const config = { type: "bar", data: { labels: sanitized.labels, datasets: [ { label: title, data: sanitized.values, backgroundColor: "#4e79a7" } ] }, options: { plugins: { title: { display: true, text: title }, legend: { display: false } }, responsive: false, scales: { x: { ticks: { maxRotation: 45, minRotation: 45, autoSkip: false } }, y: { beginAtZero: true } } } }; const mime = format === "svg" ? "image/svg+xml" : "image/png"; const buffer = await canvas.renderToBuffer(config, mime); fs.writeFileSync(filePath, buffer); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); console.error(`[error] Failed to generate PNG chart: ${message}`); await renderSVGFallback( filePath, () => generateBarChartSVG(title, sanitized.labels, sanitized.values, { limit }), `Fallback SVG generation failed for ${filePath}` ); } } async function renderHeatmapImage(format, heatmap, filePath, options = {}) { if (!validateAndPrepareDirectory(filePath)) return; const { width = 900, height = 220, verbose = false } = options; const sanitizedHeatmap = !heatmap || !Array.isArray(heatmap) || heatmap.length === 0 ? (() => { if (verbose) { console.warn("[warn] Invalid heatmap data, creating empty heatmap"); } return Array.from({ length: 7 }, () => new Array(24).fill(0)); })() : heatmap; if (!shouldUsePNG(format)) { await renderSVGFallback( filePath, () => generateHeatmapSVG(sanitizedHeatmap), `Failed to generate SVG heatmap ${filePath}` ); return; } try { const canvas = createCanvas(format, width, height); if (!canvas) { await renderSVGFallback( filePath, () => generateHeatmapSVG(sanitizedHeatmap), `Canvas creation failed for ${filePath}` ); return; } const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const hours = Array.from({ length: 24 }, (_, i) => i); const datasets = sanitizedHeatmap.map((row, i) => ({ label: days[i], data: row, backgroundColor: row.map((val) => { const maxVal = Math.max(1, Math.max(...row)); const alpha = val > 0 ? Math.min(1, 0.15 + 0.85 * (val / maxVal)) : 0.05; return `rgba(78,121,167,${alpha})`; }), borderWidth: 0, type: "bar", barPercentage: 1, categoryPercentage: 1 })); const config = { type: "bar", data: { labels: hours, datasets }, options: { indexAxis: "y", plugins: { title: { display: true, text: "Commit Activity Heatmap (weekday x hour)" }, legend: { display: false } }, responsive: false, scales: { x: { stacked: true, beginAtZero: true }, y: { stacked: true } } } }; const mime = format === "svg" ? "image/svg+xml" : "image/png"; const buffer = await canvas.renderToBuffer(config, mime); fs.writeFileSync(filePath, buffer); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); console.error(`[error] Failed to generate PNG heatmap: ${message}`); await renderSVGFallback( filePath, () => generateHeatmapSVG(sanitizedHeatmap), `Fallback SVG generation failed for ${filePath}` ); } } async function generateCharts(final, opts = {}, outDir) { const chartsRequested = opts.charts; if (!chartsRequested) return; const chartsDir = outDir || opts.chartsDir || path.join(process.cwd(), "charts"); ensureDir(chartsDir); const formatOpt = String(opts.chartFormat || "svg").toLowerCase(); const formats = formatOpt === "both" ? ["svg", "png"] : [formatOpt === "png" ? "png" : "svg"]; const names = final.topContributors.map((c) => String(c.name || "")); const commitsVals = final.topContributors.map((c) => Number(c.commits || 0)); const netVals = final.topContributors.map((c) => Number(c.added || 0) - Number(c.deleted || 0)); const tasks = []; for (const fmt of formats) { const ext = fmt === "svg" ? ".svg" : ".png"; tasks.push( renderBarChartImage( fmt, "Top contributors by commits", names, commitsVals, path.join(chartsDir, `top-commits${ext}`), { limit: 25, verbose: opts.verbose } ), renderBarChartImage( fmt, "Top contributors by net lines", names, netVals, path.join(chartsDir, `top-net${ext}`), { limit: 25, verbose: opts.verbose } ), renderHeatmapImage(fmt, final.heatmap, path.join(chartsDir, `heatmap${ext}`), { verbose: opts.verbose }) ); } await Promise.all(tasks); if (formats.includes("svg")) { await ensureFallbackSVGs(chartsDir, names, commitsVals, netVals, final.heatmap, opts.verbose); } console.error(`Wrote ${formats.join("+").toUpperCase()} charts to ${chartsDir}`); } async function ensureFallbackSVGs(chartsDir, names, commitsVals, netVals, heatmap, verbose) { const svgFiles = [ { path: path.join(chartsDir, "top-commits.svg"), gen: () => generateBarChartSVG("Top contributors by commits", names, commitsVals, { limit: 25 }) }, { path: path.join(chartsDir, "top-net.svg"), gen: () => generateBarChartSVG("Top contributors by net lines", names, netVals, { limit: 25 }) }, { path: path.join(chartsDir, "heatmap.svg"), gen: () => generateHeatmapSVG(heatmap) } ]; for (const { path: svgPath, gen } of svgFiles) { if (!fs.existsSync(svgPath)) { try { fs.writeFileSync(svgPath, gen(), "utf8"); } catch (e) { if (verbose) console.error("[error] Fallback write failed", svgPath, e.message); } } } } export { generateCharts as g }; //# sourceMappingURL=charts.mjs.map