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
JavaScript
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