UNPKG

valaxy-addon-git-log

Version:

Integrates git logs into your page of Valaxy site.

208 lines (180 loc) 6.58 kB
import type { Contributor, GitLogOptions } from '../types' import { execFileSync } from 'node:child_process' import consola from 'consola' import gravatar from 'gravatar' import md5 from 'md5' import { git } from '.' import { guessGitHubUsername, parseGithubUrl, resolveGitHubUsers } from '../utils' /** * Create a Contributor object from a name and email. * Shared by both single-file and batch contributor fetching. */ export function createContributor(name: string, email: string): Contributor { const githubUsername = guessGitHubUsername(email) const gravatarUrl = gravatar.url(email) return { count: 0, name, email, avatar: githubUsername ? `https://github.com/${githubUsername}.png` : gravatarUrl.startsWith('//') ? `https:${gravatarUrl}` : gravatarUrl, github: githubUsername ? `https://github.com/${githubUsername}` : null, hash: md5(email), } } /** * Apply a previously-resolved `email -> login` map onto contributors that * don't have a GitHub link yet. Mutates the items in-place. * * Used to reuse logins persisted in `git-log.json` from earlier builds so we * only hit the GitHub API for emails we have never resolved before. */ export function applyKnownLogins( contributors: Contributor[], knownLogins?: Map<string, string>, ): void { if (!knownLogins?.size) return for (const contributor of contributors) { if (contributor.github) continue const login = knownLogins.get(contributor.email) if (login) { contributor.github = `https://github.com/${login}` contributor.avatar = `https://github.com/${login}.png` } } } /** * Resolve GitHub usernames for contributors that don't have one yet. * Uses the GitHub API to look up emails from the repository's commit history. * * Pass `knownLogins` (an `email -> login` map, typically reconstructed from a * committed `git-log.json`) to skip emails already resolved by a prior build, * minimizing — and often eliminating — GitHub API calls. * * **Note:** This function mutates the `contributors` array items in-place, * updating their `github` and `avatar` fields when a match is found. */ export async function resolveContributorsGitHub( contributors: Contributor[], repositoryUrl?: string, knownLogins?: Map<string, string>, ): Promise<Contributor[]> { if (!repositoryUrl) return contributors // Seed from cache first so resolved emails are excluded from the API lookup. applyKnownLogins(contributors, knownLogins) const unresolved = contributors.filter(c => !c.github) if (!unresolved.length) return contributors let owner: string let repo: string try { ({ owner, repo } = parseGithubUrl(repositoryUrl)) } catch { return contributors } const emailsToResolve = unresolved.map(c => c.email) const emailToLogin = await resolveGitHubUsers(owner, repo, emailsToResolve) for (const contributor of contributors) { if (contributor.github) continue const login = emailToLogin.get(contributor.email) if (login) { contributor.github = `https://github.com/${login}` contributor.avatar = `https://github.com/${login}.png` } } return contributors } /** * Deduplicate contributors: merge entries that share the same name * when at least one of them has a recognized GitHub noreply email, * so that e.g. "user@gmail.com" and "12345+user@users.noreply.github.com" * are merged into a single entry with GitHub avatar & link. * * Uses O(n) grouping by name instead of O(n²) nested loops. */ export function deduplicateContributors(contributors: Contributor[]): Contributor[] { const byName = new Map<string, Contributor[]>() for (const c of contributors) { const group = byName.get(c.name) if (group) group.push(c) else byName.set(c.name, [c]) } const merged: Contributor[] = [] for (const group of byName.values()) { if (group.length === 1) { merged.push(group[0]) continue } // Only merge when at least one entry has GitHub info const withGithub = group.filter(c => c.github) const withoutGithub = group.filter(c => !c.github) if (withGithub.length === 0) { // No github info — keep all separate (different people with same name) merged.push(...group) continue } // Merge all into the first github-having entry const base = { ...withGithub[0] } for (let i = 1; i < withGithub.length; i++) base.count += withGithub[i].count for (const c of withoutGithub) base.count += c.count merged.push(base) } return merged } export async function getContributors(filePath?: string, options?: GitLogOptions): Promise<Contributor[]> { const { contributor } = options || {} try { const gitArgs: string[] = ['log', '--no-merges', '--pretty=format:%an\x1F%ae'] const additionalArgs: string[] = [ filePath && `--`, filePath, contributor?.logArgs, ].filter((arg): arg is string => arg != null) const gitLog = await git.raw([...gitArgs, ...additionalArgs]) const contributorsMap = gitLog .split('\n') .map(line => line.split('\x1F') as [string, string]) .filter(([_, email]) => email) .reduce((acc, [name, email]) => { if (!acc[email]) { acc[email] = createContributor(name, email) } acc[email].count++ return acc }, {} as Record<string, Contributor>) const contributors = Object.values(contributorsMap) return deduplicateContributors(contributors).sort((a, b) => b.count - a.count) } catch (e) { consola.error('valaxy-addon-git-log: Error fetching contributors:', e) return [] } } /** * Get the last updated timestamp of a file from git history (synchronous). * Kept synchronous for backward compatibility — consumers may call this in * sync contexts (e.g. Vite plugin transforms). Use {@link getLastUpdatedAsync} * in async pipelines to avoid blocking the event loop. */ export function getLastUpdated(filePath: string): number { const result = execFileSync('git', ['log', '-1', '--format=%ct', '--', filePath], { encoding: 'utf-8' }) return Number.parseInt(result.trim(), 10) * 1000 } /** * Async variant of {@link getLastUpdated}, using simple-git to avoid * blocking the event loop. Prefer this in async build pipelines. */ export async function getLastUpdatedAsync(filePath: string): Promise<number> { const result = await git.raw(['log', '-1', '--format=%ct', '--', filePath]) return Number.parseInt(result.trim(), 10) * 1000 }