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.

1 lines 9.86 kB
{"version":3,"file":"stats.mjs","sources":["../../src/features/stats.ts"],"sourcesContent":["// Feature: Core statistics generation\n// This module provides the main getContributorStats function for library consumers\n\nimport path from 'node:path';\nimport type { ContributorsMeta } from '../analytics/aggregator.ts';\nimport { aggregateBasic, computeMeta, pickSortMetric } from '../analytics/aggregator.ts';\nimport { type AliasConfig, buildAliasResolver } from '../analytics/aliases.ts';\nimport type {\n ContributorsMapEntry,\n TopContributor,\n TopStatsSummary\n} from '../analytics/analyzer.ts';\nimport { analyze } from '../analytics/analyzer.ts';\nimport { parseGitLog } from '../git/parser.ts';\nimport { buildGitLogArgs, isGitRepo, runGit } from '../git/utils.ts';\nimport { parseDateInput } from '../utils/dates.ts';\nimport { countTotalLines, tryLoadJSON } from '../utils/files.ts';\n\nexport interface ContributorStatsOptions {\n repo?: string;\n branch?: string;\n paths?: string | string[];\n since?: string;\n until?: string;\n author?: string;\n includeMerges?: boolean;\n groupBy?: 'email' | 'name';\n labelBy?: 'email' | 'name';\n sortBy?: 'changes' | 'commits' | 'additions' | 'deletions';\n top?: number;\n similarity?: number;\n aliasFile?: string;\n aliasConfig?: AliasConfig;\n countLines?: boolean;\n verbose?: boolean;\n}\n\nexport interface CommitFrequencyBreakdown {\n monthly: Record<string, number>;\n weekly: Record<string, number>;\n}\n\nexport interface BusFactorInfo {\n busFactor: number;\n candidates: string[];\n details?: Record<string, unknown>;\n filesSingleOwner?: { file: string; owner: string; changes: number }[];\n}\n\nexport interface ContributorStatsResult {\n meta: {\n generatedAt: string;\n repo: string;\n branch: string | null;\n since: string | null;\n until: string | null;\n };\n totalCommits: number;\n totalLines: number;\n contributors: Record<string, ContributorsMapEntry>;\n topContributors: TopContributor[];\n topStats: TopStatsSummary;\n commitFrequency: CommitFrequencyBreakdown;\n heatmap: number[][];\n heatmapContributors: Record<string, Record<string, number>>;\n busFactor: BusFactorInfo;\n basic: {\n meta: ContributorsMeta;\n groupBy: 'email' | 'name';\n labelBy: 'email' | 'name';\n };\n}\n\nfunction loadAliasConfig(\n opts: ContributorStatsOptions,\n repo: string\n): { config: AliasConfig; path: string | null } {\n let aliasConfig: AliasConfig = opts.aliasConfig ?? undefined;\n let aliasPath: string | null = null;\n\n if (!aliasConfig) {\n if (opts.aliasFile) {\n aliasPath = path.resolve(process.cwd(), opts.aliasFile);\n aliasConfig = tryLoadJSON(aliasPath) as AliasConfig;\n } else {\n const defaultAlias = path.join(repo, '.git-contributor-stats-aliases.json');\n aliasConfig = tryLoadJSON(defaultAlias) as AliasConfig;\n if (aliasConfig) aliasPath = defaultAlias;\n }\n }\n\n return { config: aliasConfig, path: aliasPath };\n}\n\nfunction normalizePaths(paths: string | string[] | undefined): string[] {\n if (Array.isArray(paths)) {\n return paths;\n }\n if (paths) {\n return [paths];\n }\n return [];\n}\n\nfunction getRepoBranch(repo: string, optsBranch?: string): string | null {\n if (optsBranch) return optsBranch;\n const result = runGit(repo, ['rev-parse', '--abbrev-ref', 'HEAD']);\n return result.stdout?.trim() || null;\n}\n\nexport async function getContributorStats(\n opts: ContributorStatsOptions = {}\n): Promise<ContributorStatsResult> {\n const repo = path.resolve(process.cwd(), opts.repo || '.');\n if (!isGitRepo(repo)) throw new Error(`Not a Git repository: ${repo}`);\n\n const debug = (...msg: unknown[]) => {\n if (opts.verbose) console.error('[debug]', ...msg);\n };\n\n const { config: aliasConfig, path: aliasPath } = loadAliasConfig(opts, repo);\n if (aliasPath) debug(`aliasFile=${aliasPath}`);\n debug(`repo=${repo}`);\n\n const { resolve: aliasResolveFn, canonicalDetails } = buildAliasResolver(aliasConfig);\n\n const since = parseDateInput(opts.since);\n const until = parseDateInput(opts.until);\n const paths = normalizePaths(opts.paths);\n\n const gitArgs = buildGitLogArgs({\n branch: opts.branch,\n since,\n until,\n author: opts.author,\n includeMerges: !!opts.includeMerges,\n paths\n });\n debug('git', gitArgs.map((a) => (a.includes(' ') ? `'${a}'` : a)).join(' '));\n\n const result = runGit(repo, gitArgs);\n if (!result.ok) throw new Error(result.error || 'Failed to run git log');\n const commits = parseGitLog(result.stdout);\n debug(`parsed commits: ${commits.length}`);\n\n const groupBy = (opts.groupBy || 'email').toLowerCase() === 'name' ? 'name' : 'email';\n const labelBy = opts.labelBy?.toLowerCase() === 'email' ? 'email' : 'name';\n const similarityThreshold = opts.similarity ?? 0.85;\n\n let contributorsBasic = aggregateBasic(commits, {\n groupBy,\n aliasResolver: aliasResolveFn,\n canonicalDetails,\n similarity: similarityThreshold\n });\n const sorter = pickSortMetric(opts.sortBy || 'changes');\n contributorsBasic.sort(sorter);\n if (opts.top && Number.isFinite(opts.top) && opts.top > 0) {\n contributorsBasic = contributorsBasic.slice(0, opts.top);\n }\n const meta: ContributorsMeta = computeMeta(contributorsBasic);\n\n const analysis = analyze(commits, similarityThreshold, aliasResolveFn, canonicalDetails, groupBy);\n\n let topContributors = analysis.topContributors;\n if (opts.sortBy && opts.sortBy !== 'commits') {\n const metricSorter = pickSortMetric(opts.sortBy);\n topContributors = [...topContributors].sort(metricSorter);\n }\n if (opts.top && Number.isFinite(opts.top) && opts.top > 0) {\n topContributors = topContributors.slice(0, opts.top);\n }\n\n let totalLines = 0;\n if (opts.countLines !== false) {\n totalLines = await countTotalLines(repo, runGit);\n }\n\n const repoRootResult = runGit(repo, ['rev-parse', '--show-toplevel']);\n const repoRoot = repoRootResult.ok ? (repoRootResult.stdout?.trim() ?? repo) : repo;\n const branch = getRepoBranch(repo, opts.branch);\n\n return {\n meta: {\n generatedAt: new Date().toISOString(),\n repo: repoRoot,\n branch,\n since: since || null,\n until: until || null\n },\n totalCommits: analysis.totalCommits,\n totalLines,\n contributors: analysis.contributors,\n topContributors,\n topStats: analysis.topStats,\n commitFrequency: analysis.commitFrequency,\n heatmap: analysis.heatmap,\n heatmapContributors: analysis.heatmapContributors,\n busFactor: {\n busFactor: analysis.busFactor.busFactor ?? 0,\n candidates: analysis.busFactor.candidates ?? [],\n details: analysis.busFactor.details,\n filesSingleOwner: analysis.busFactor.filesSingleOwner\n },\n basic: { meta, groupBy, labelBy }\n };\n}\n"],"names":[],"mappings":"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"}