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.

579 lines (578 loc) 18.8 kB
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