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.
579 lines (578 loc) • 18.8 kB
JavaScript
import { f as formatNumber, n as normalizeContributorName, c as calculateSimilarityScore, i as isoWeekKey } from "./utils-CFufYn8A.mjs";
function normalizeKey(commit, groupBy, aliasResolver) {
const name = commit.authorName || "";
const email = commit.authorEmail || "";
const valueToNormalize = groupBy === "email" ? email || name : name || email;
const baseNorm = normalizeContributorName(valueToNormalize);
return aliasResolver ? aliasResolver(baseNorm, name, email) : baseNorm;
}
function getDisplayDetails(normalizedKey, defaultName, defaultEmail, canonicalDetails) {
if (canonicalDetails?.has(normalizedKey)) {
const info = canonicalDetails.get(normalizedKey);
if (info) {
return {
name: info.name || defaultName,
email: info.email || defaultEmail
};
}
}
return { name: defaultName, email: defaultEmail };
}
function findSimilarKey(norm, existingKeys, threshold) {
for (const mk of existingKeys) {
const sim = calculateSimilarityScore(norm, mk);
if (sim >= threshold) {
return mk;
}
}
return null;
}
function mergeEmails(target, source) {
for (const email of source) {
target.add(email);
}
}
function mergeFirstCommitDate(target, source) {
if (source.firstCommitDate && (!target.firstCommitDate || source.firstCommitDate < target.firstCommitDate)) {
target.firstCommitDate = source.firstCommitDate;
}
}
function mergeLastCommitDate(target, source) {
if (source.lastCommitDate && (!target.lastCommitDate || source.lastCommitDate > target.lastCommitDate)) {
target.lastCommitDate = source.lastCommitDate;
}
}
function mergeAggregationData(target, source) {
target.commits += source.commits;
target.additions += source.additions;
target.deletions += source.deletions;
mergeEmails(target.emails, source.emails);
mergeFirstCommitDate(target, source);
mergeLastCommitDate(target, source);
}
function applySimilarityMerging(aggregations, threshold) {
const merged = [];
for (const agg of aggregations) {
const similarKey = findSimilarKey(
agg.key,
merged.map((m) => m.key),
threshold
);
if (similarKey) {
const target = merged.find((m) => m.key === similarKey);
if (target) {
mergeAggregationData(target, agg);
}
} else {
merged.push(agg);
}
}
return merged;
}
function createInitialAggregation(commit, key) {
return {
key,
name: commit.authorName || "",
emails: /* @__PURE__ */ new Set(),
commits: 0,
additions: 0,
deletions: 0,
firstCommitDate: commit.date ? new Date(commit.date) : void 0,
lastCommitDate: commit.date ? new Date(commit.date) : void 0
};
}
function updateAggregation(agg, commit) {
agg.name = agg.name || commit.authorName || "";
if (commit.authorEmail) {
agg.emails.add(commit.authorEmail.toLowerCase());
}
agg.commits += 1;
agg.additions += commit.additions || 0;
agg.deletions += commit.deletions || 0;
updateCommitDates(agg, commit);
}
function updateCommitDates(agg, commit) {
if (!commit.date) return;
const date = new Date(commit.date);
if (!agg.firstCommitDate || date < agg.firstCommitDate) {
agg.firstCommitDate = date;
}
if (!agg.lastCommitDate || date > agg.lastCommitDate) {
agg.lastCommitDate = date;
}
}
function convertToContributor(agg, groupBy) {
return {
key: agg.key,
name: agg.name || (groupBy === "name" ? agg.key : ""),
emails: Array.from(agg.emails),
commits: agg.commits,
additions: agg.additions,
deletions: agg.deletions,
changes: agg.additions + agg.deletions,
firstCommitDate: agg.firstCommitDate ? agg.firstCommitDate.toISOString() : void 0,
lastCommitDate: agg.lastCommitDate ? agg.lastCommitDate.toISOString() : void 0
};
}
function aggregateBasic(commits, options) {
const { groupBy, aliasResolver, canonicalDetails, similarity } = options;
const map = /* @__PURE__ */ new Map();
for (const commit of commits) {
const key = normalizeKey(commit, groupBy, aliasResolver);
if (!map.has(key)) {
const { name, email } = getDisplayDetails(
key,
commit.authorName || "",
commit.authorEmail || "",
canonicalDetails
);
const agg2 = createInitialAggregation(commit, key);
agg2.name = name || agg2.name;
if (email) {
agg2.emails.add(email.toLowerCase());
}
map.set(key, agg2);
}
const agg = map.get(key);
if (agg) {
updateAggregation(agg, commit);
}
}
let aggregations = Array.from(map.values());
if (similarity !== void 0 && similarity > 0) {
aggregations = applySimilarityMerging(aggregations, similarity);
}
return aggregations.map((agg) => convertToContributor(agg, groupBy));
}
function pickSortMetric(by) {
switch ((by || "").toLowerCase()) {
case "commits":
return (a, b) => b.commits - a.commits || b.changes - a.changes;
case "additions":
case "adds":
case "lines-added":
return (a, b) => (b.additions || 0) - (a.additions || 0) || b.commits - a.commits;
case "deletions":
case "dels":
case "lines-deleted":
return (a, b) => (b.deletions || 0) - (a.deletions || 0) || b.commits - a.commits;
default:
return (a, b) => b.changes - a.changes || b.commits - a.commits;
}
}
function computeMeta(contributors) {
let commits = 0, additions = 0, deletions = 0;
let first, last;
for (const c of contributors) {
commits += c.commits || 0;
additions += c.additions || 0;
deletions += c.deletions || 0;
if (c.firstCommitDate) {
const d = new Date(c.firstCommitDate);
if (!first || d < first) first = d;
}
if (c.lastCommitDate) {
const d = new Date(c.lastCommitDate);
if (!last || d > last) last = d;
}
}
return {
contributors: contributors.length,
commits,
additions,
deletions,
firstCommitDate: first ? first.toISOString() : void 0,
lastCommitDate: last ? last.toISOString() : void 0
};
}
function printTable(contributors, meta, labelBy = "name") {
const headers = [
"#",
labelBy === "name" ? "Author" : "Email",
"Commits",
"+Additions",
"-Deletions",
"±Changes"
];
const rows = [];
for (let idx = 0; idx < contributors.length; idx++) {
const c = contributors[idx];
const label = labelBy === "name" ? c.name || "(unknown)" : c.key || "(unknown)";
rows.push([
String(idx + 1),
label,
formatNumber(c.commits),
formatNumber(c.additions),
formatNumber(c.deletions),
formatNumber(c.changes)
]);
}
const colWidths = headers.map(
(h, i) => Math.max(h.length, ...rows.map((r) => r[i] ? String(r[i]).length : 0))
);
const headerLine = headers.map((h, i) => i === 1 ? String(h).padEnd(colWidths[i]) : String(h).padStart(colWidths[i])).join(" ");
const sepLine = colWidths.map((w) => "-".repeat(w)).join(" ");
console.log(headerLine);
console.log(sepLine);
for (const r of rows) {
const line = r.map(
(cell, i) => i === 1 ? String(cell).padEnd(colWidths[i]) : String(cell).padStart(colWidths[i])
).join(" ");
console.log(line);
}
console.log();
console.log(
`Contributors: ${formatNumber(meta.contributors)} | Commits: ${formatNumber(meta.commits)} | Changes: ${formatNumber(
meta.additions + meta.deletions
)} (+${formatNumber(meta.additions)} / -${formatNumber(meta.deletions)})`
);
if (meta.firstCommitDate || meta.lastCommitDate) {
console.log(
`Range: ${meta.firstCommitDate ? new Date(meta.firstCommitDate).toISOString().slice(0, 10) : "—"} → ${meta.lastCommitDate ? new Date(meta.lastCommitDate).toISOString().slice(0, 10) : "—"}`
);
}
}
function printCSV(contributors, labelBy = "name") {
const header = [
"rank",
labelBy === "name" ? "author" : "email",
"commits",
"additions",
"deletions",
"changes"
];
console.log(header.join(","));
for (let i = 0; i < contributors.length; i++) {
const c = contributors[i];
const label = labelBy === "name" ? c.name || "" : c.key || "";
console.log([i + 1, label, c.commits, c.additions, c.deletions, c.changes].join(","));
}
}
function extractMapEntries(config) {
if (config.map && typeof config.map === "object") {
return Object.entries(config.map);
}
const flatMapCandidates = Object.keys(config).filter(
(k) => k !== "groups" && k !== "map" && k !== "canonical"
);
if (flatMapCandidates.length === 0) {
return [];
}
return Object.entries(config).filter(([k, v]) => k !== "groups" && k !== "canonical" && typeof v === "string").map(([k, v]) => [k, v]);
}
function extractCanonicalDetails(config) {
const canonicalDetails = /* @__PURE__ */ new Map();
if (!config.canonical || typeof config.canonical !== "object") {
return canonicalDetails;
}
for (const [canonKey, info] of Object.entries(config.canonical)) {
const normKey = normalizeContributorName(canonKey);
const infoObj = info;
canonicalDetails.set(normKey, {
name: (infoObj && typeof infoObj.name === "string" ? infoObj.name : void 0) || void 0,
email: (infoObj && typeof infoObj.email === "string" ? infoObj.email : void 0) || void 0
});
}
return canonicalDetails;
}
function parseAliasConfig(config) {
const emptyResult = {
mapEntries: [],
groups: [],
canonicalDetails: /* @__PURE__ */ new Map()
};
if (!config) {
return emptyResult;
}
if (Array.isArray(config)) {
return {
...emptyResult,
groups: config
};
}
if (typeof config === "object") {
const groups = Array.isArray(config.groups) ? config.groups : [];
const mapEntries = extractMapEntries(config);
const canonicalDetails = extractCanonicalDetails(config);
return { mapEntries, groups, canonicalDetails };
}
return emptyResult;
}
function parseRegexPattern(pattern) {
if (!pattern.startsWith("/") || pattern.lastIndexOf("/") <= 0) {
return null;
}
const lastSlash = pattern.lastIndexOf("/");
const regexPattern = pattern.slice(1, lastSlash);
const flags = pattern.slice(lastSlash + 1);
try {
return new RegExp(regexPattern, flags);
} catch (_error) {
return null;
}
}
function processMapEntries(mapEntries, aliasMap, regexList) {
for (const [alias, canonical] of mapEntries) {
const regex = parseRegexPattern(alias);
if (regex) {
regexList.push({ regex, canonical: normalizeContributorName(canonical) });
} else {
aliasMap.set(normalizeContributorName(alias), normalizeContributorName(canonical));
}
}
}
function processGroups(groups, aliasMap, regexList) {
for (const g of groups) {
if (!Array.isArray(g) || g.length === 0) continue;
const canonicalCandidate = g.find((s) => s.includes("@")) || g[0];
const canonicalNorm = normalizeContributorName(String(canonicalCandidate));
for (const item of g) {
const regex = parseRegexPattern(item);
if (regex) {
regexList.push({ regex, canonical: canonicalNorm });
} else {
aliasMap.set(normalizeContributorName(item), canonicalNorm);
}
}
}
}
function createResolveFunction(aliasMap, regexList) {
return function resolve(baseNorm, name, email) {
const mapped = aliasMap.get(baseNorm);
if (mapped) return mapped;
const rawName = name || "";
const rawEmail = email || "";
for (const { regex, canonical } of regexList) {
try {
if (regex.test(rawName) || regex.test(rawEmail)) {
return canonical;
}
} catch (_error) {
}
}
return baseNorm;
};
}
function buildAliasResolver(config) {
if (!config) {
return {
resolve: null,
canonicalDetails: /* @__PURE__ */ new Map()
};
}
const { mapEntries, groups, canonicalDetails } = parseAliasConfig(config);
const aliasMap = /* @__PURE__ */ new Map();
const regexList = [];
processMapEntries(mapEntries, aliasMap, regexList);
processGroups(groups, aliasMap, regexList);
const resolve = createResolveFunction(aliasMap, regexList);
return { resolve, canonicalDetails };
}
function mergeFilesIntoTarget(target, src) {
for (const [fName, info] of Object.entries(src.files || {})) {
const inf = info;
if (!target.files[fName]) {
target.files[fName] = { changes: 0, added: 0, deleted: 0 };
}
target.files[fName].changes += inf.changes;
target.files[fName].added += inf.added;
target.files[fName].deleted += inf.deleted;
}
}
function mergeContributorIntoTarget(target, src) {
target.commits += src.commits;
target.added += src.added;
target.deleted += src.deleted;
mergeFilesIntoTarget(target, src);
}
function mergeSimilarContributors(contribMap, threshold) {
const keys = Object.keys(contribMap);
const merged = {};
for (const key of keys) {
const found = findSimilarKey(key, Object.keys(merged), threshold);
if (found) {
mergeContributorIntoTarget(merged[found], contribMap[key]);
} else {
const src = contribMap[key];
merged[key] = {
normalized: key,
name: src.name,
email: src.email,
commits: src.commits,
added: src.added,
deleted: src.deleted,
files: { ...src.files }
};
}
}
return merged;
}
function getOrCreateContributor(contribMap, normalized, name, email, canonicalDetails) {
if (!contribMap[normalized]) {
const { name: displayName, email: displayEmail } = getDisplayDetails(
normalized,
name,
email,
canonicalDetails
);
contribMap[normalized] = {
name: displayName,
email: displayEmail,
commits: 0,
added: 0,
deleted: 0,
files: {}
};
}
return contribMap[normalized];
}
function updateCommitFrequency(date, commitFrequencyMonthly, commitFrequencyWeekly, heatmap, heatmapContributors, contributorName) {
if (!date) return;
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
commitFrequencyMonthly[monthKey] = (commitFrequencyMonthly[monthKey] || 0) + 1;
const weekKey = isoWeekKey(date);
commitFrequencyWeekly[weekKey] = (commitFrequencyWeekly[weekKey] || 0) + 1;
const day = date.getDay();
const hour = date.getHours();
heatmap[day][hour] += 1;
const key = `${day}-${hour}`;
if (!heatmapContributors[key]) {
heatmapContributors[key] = {};
}
heatmapContributors[key][contributorName] = (heatmapContributors[key][contributorName] || 0) + 1;
}
function processCommitFiles(commit, contrib, fileToContributors, normalized) {
for (const f of commit.files || []) {
const fName = f.filename;
contrib.added += f.added;
contrib.deleted += f.deleted;
if (!contrib.files[fName]) {
contrib.files[fName] = { changes: 0, added: 0, deleted: 0 };
}
contrib.files[fName].changes += f.added + f.deleted;
contrib.files[fName].added += f.added;
contrib.files[fName].deleted += f.deleted;
if (!fileToContributors[fName]) {
fileToContributors[fName] = /* @__PURE__ */ new Set();
}
fileToContributors[fName].add(normalized);
}
}
function buildTopContributors(merged) {
return Object.values(merged).map((c) => {
const filesArr = Object.entries(c.files || {}).map(([filename, info]) => {
const inf = info;
return {
filename,
changes: inf.changes,
added: inf.added,
deleted: inf.deleted
};
});
filesArr.sort((a, b) => b.changes - a.changes);
return {
name: c.name,
email: c.email,
commits: c.commits,
added: c.added,
deleted: c.deleted,
net: c.added - c.deleted,
changes: c.added + c.deleted,
files: c.files,
topFiles: filesArr
};
}).sort((a, b) => b.commits - a.commits);
}
function buildBusFactor(fileToContributors, merged, contribMap) {
const filesSingleOwner = [];
for (const [file, ownersSet] of Object.entries(fileToContributors)) {
const owners = Array.from(ownersSet);
if (owners.length === 1) {
const owner = owners[0];
const m = merged[owner] || contribMap[owner] || { name: owner };
const ownerEntry = merged[owner] || contribMap[owner];
const changes = ownerEntry?.files?.[file]?.changes ?? 0;
filesSingleOwner.push({ file, owner: m.name || owner, changes });
}
}
filesSingleOwner.sort((a, b) => b.changes - a.changes);
return filesSingleOwner;
}
function buildTopStats(topContributors) {
function topBy(metric) {
const arr = [...topContributors];
arr.sort((a, b) => (b[metric] || 0) - (a[metric] || 0));
return arr[0] || null;
}
return {
byCommits: topBy("commits"),
byAdditions: topBy("added"),
byDeletions: topBy("deleted"),
byNet: topBy("net"),
byChanges: topBy("changes")
};
}
function analyze(commits, similarityThreshold, aliasResolver, canonicalDetails, groupBy = "email") {
const contribMap = {};
const fileToContributors = {};
let totalCommits = 0;
const commitFrequencyMonthly = {};
const commitFrequencyWeekly = {};
const heatmap = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));
const heatmapContributors = {};
for (const commit of commits) {
totalCommits++;
const name = commit.authorName || "";
const email = commit.authorEmail || "";
const normalized = normalizeKey(
commit,
groupBy,
aliasResolver
);
const contrib = getOrCreateContributor(contribMap, normalized, name, email, canonicalDetails);
contrib.commits += 1;
const date = commit.date ? new Date(commit.date) : null;
updateCommitFrequency(
date,
commitFrequencyMonthly,
commitFrequencyWeekly,
heatmap,
heatmapContributors,
name
);
processCommitFiles(commit, contrib, fileToContributors, normalized);
}
const merged = mergeSimilarContributors(contribMap, similarityThreshold);
const topContributors = buildTopContributors(merged);
const filesSingleOwner = buildBusFactor(fileToContributors, merged, contribMap);
const topStats = buildTopStats(topContributors);
const busFactorInfo = {
busFactor: 0,
// TODO: implement actual bus factor calculation if needed
candidates: [],
// TODO: implement candidate calculation if needed
details: void 0,
// or provide details if available
filesSingleOwner
};
return {
contributors: merged,
topContributors,
totalCommits,
commitFrequency: { monthly: commitFrequencyMonthly, weekly: commitFrequencyWeekly },
heatmap,
heatmapContributors,
busFactor: busFactorInfo,
topStats
};
}
export {
aggregateBasic as a,
buildAliasResolver as b,
computeMeta as c,
analyze as d,
printCSV as e,
printTable as f,
pickSortMetric as p
};
//# sourceMappingURL=analytics-SL4YC1kG.mjs.map