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.
183 lines (182 loc) • 6.59 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
function safeReadPackageJson() {
try {
const pkgPath = path.join(process.cwd(), "package.json");
return JSON.parse(fs.readFileSync(pkgPath, { encoding: "utf8" }));
} catch {
return {};
}
}
function ensureDir(dir) {
if (!dir) return;
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function tryLoadJSON(filePath) {
try {
const txt = fs.readFileSync(filePath, "utf8");
return JSON.parse(txt);
} catch {
return null;
}
}
async function countTotalLines(repoPath, runGit) {
try {
const res = runGit(repoPath, ["ls-files"]);
if (!res.ok) return 0;
const files = res.stdout?.split(/\r?\n/).filter(Boolean);
if (!files) return 0;
let total = 0;
for (const rel of files) {
const abs = path.join(repoPath, rel);
try {
const stat = fs.statSync(abs);
if (!stat.isFile() || stat.size > 50 * 1024 * 1024) continue;
const content = fs.readFileSync(abs, "utf8");
total += content.split(/\r?\n/).length;
} catch {
}
}
return total;
} catch {
return 0;
}
}
function formatNumber(n) {
return new Intl.NumberFormat().format(n);
}
function svgEscape(s) {
return String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
}
function parseTopStatsMetrics(input) {
const all = ["commits", "additions", "deletions", "net", "changes"];
if (!input) return all;
const set = /* @__PURE__ */ new Set();
for (const part of String(input).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)) {
if (all.includes(part)) set.add(part);
}
return set.size ? Array.from(set) : all;
}
function getMetricValue(entry, metricKey) {
if (typeof entry[metricKey] === "number") {
return entry[metricKey];
}
if (metricKey === "net") {
return (entry.added || 0) - (entry.deleted || 0);
}
return void 0;
}
function formatTopStatsLines(ts, metrics) {
const lines = [];
const want = new Set(
metrics?.length ? metrics : ["commits", "additions", "deletions", "net", "changes"]
);
function line(label, entry, metricKey) {
if (!entry) return `${label}: —`;
const metricVal = getMetricValue(entry, metricKey);
const suffix = typeof metricVal === "number" ? ` (${metricVal})` : "";
const emailPart = entry.email ? ` <${entry.email}>` : "";
const who = `${entry.name || "—"}${emailPart}`;
return `${label}: ${who}${suffix}`;
}
if (want.has("commits")) lines.push(line("Most commits", ts.byCommits, "commits"));
if (want.has("additions")) lines.push(line("Most additions", ts.byAdditions, "added"));
if (want.has("deletions")) lines.push(line("Most deletions", ts.byDeletions, "deleted"));
if (want.has("net")) lines.push(line("Best net lines", ts.byNet, "net"));
if (want.has("changes")) lines.push(line("Most changes (±)", ts.byChanges, "changes"));
return lines;
}
function normalizeContributorName(rawName) {
const nameAsString = String(rawName || "");
const withoutEmailDomain = nameAsString.replace(/@.*$/, "");
const onlyAlphanumericAndSeparators = withoutEmailDomain.replaceAll(/[^a-zA-Z0-9\s._-]/g, "");
const collapsedWhitespace = onlyAlphanumericAndSeparators.replaceAll(/\s+/g, " ");
const trimmedName = collapsedWhitespace.trim();
return trimmedName.toLowerCase();
}
function levenshteinDistance(firstString, secondString) {
const firstLength = firstString.length;
const secondLength = secondString.length;
if (firstLength === 0) return secondLength;
if (secondLength === 0) return firstLength;
const distanceMatrix = Array.from(
{ length: firstLength + 1 },
() => new Array(secondLength + 1).fill(0)
);
for (let rowIndex = 0; rowIndex <= firstLength; rowIndex++) {
distanceMatrix[rowIndex][0] = rowIndex;
}
for (let columnIndex = 0; columnIndex <= secondLength; columnIndex++) {
distanceMatrix[0][columnIndex] = columnIndex;
}
for (let rowIndex = 1; rowIndex <= firstLength; rowIndex++) {
for (let columnIndex = 1; columnIndex <= secondLength; columnIndex++) {
const charactersMatch = firstString[rowIndex - 1] === secondString[columnIndex - 1];
const substitutionCost = charactersMatch ? 0 : 1;
const deletionCost = distanceMatrix[rowIndex - 1][columnIndex] + 1;
const insertionCost = distanceMatrix[rowIndex][columnIndex - 1] + 1;
const substitutionTotalCost = distanceMatrix[rowIndex - 1][columnIndex - 1] + substitutionCost;
distanceMatrix[rowIndex][columnIndex] = Math.min(
deletionCost,
insertionCost,
substitutionTotalCost
);
}
}
return distanceMatrix[firstLength][secondLength];
}
function calculateSimilarityScore(firstString, secondString) {
const longerStringLength = Math.max(firstString.length, secondString.length) || 1;
const editDistance = levenshteinDistance(firstString.toLowerCase(), secondString.toLowerCase());
return 1 - editDistance / longerStringLength;
}
function parseDateInput(input) {
if (!input) return void 0;
const rel = /^(\d+)\.(day|days|week|weeks|month|months|year|years)$/i.exec(input.trim());
if (rel) {
let getUnitMultiplier = function(unitStr) {
if (unitStr.startsWith("day")) return 1;
if (unitStr.startsWith("week")) return 7;
if (unitStr.startsWith("month")) return 30;
return 365;
};
const qty = Number.parseInt(rel[1], 10);
const unit = rel[2].toLowerCase();
const now = /* @__PURE__ */ new Date();
const d2 = new Date(now);
const mult = getUnitMultiplier(unit);
d2.setDate(now.getDate() - qty * mult);
return d2.toISOString();
}
const d = new Date(input);
if (!Number.isNaN(d.getTime())) return d.toISOString();
return input;
}
function isoWeekKey(date) {
const d = new Date(date);
const target = new Date(d.valueOf());
const dayNumber = (d.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNumber + 3);
const firstThursday = new Date(target.getFullYear(), 0, 4);
const diff = (target.getTime() - firstThursday.getTime()) / 864e5;
const week = 1 + Math.round(diff / 7);
return `${target.getFullYear()}-W${String(week).padStart(2, "0")}`;
}
export {
countTotalLines as a,
svgEscape as b,
calculateSimilarityScore as c,
parseTopStatsMetrics as d,
ensureDir as e,
formatNumber as f,
getMetricValue as g,
formatTopStatsLines as h,
isoWeekKey as i,
normalizeContributorName as n,
parseDateInput as p,
safeReadPackageJson as s,
tryLoadJSON as t
};
//# sourceMappingURL=utils-CFufYn8A.mjs.map