UNPKG

valaxy-addon-git-log

Version:

Integrates git logs into your page of Valaxy site.

437 lines (371 loc) 13.9 kB
import type { EditableTreeNode } from 'vue-router/unplugin' import type { Changelog, Contributor, GitLogFileEntry, GitLogOptions } from '../types' import path from 'node:path' import process from 'node:process' import consola from 'consola' import fs from 'fs-extra' import { git } from '.' import { shouldIncludeCommit } from '../types' import { createContributor, deduplicateContributors, resolveContributorsGitHub } from './contributor' /** * ASCII Unit Separator — used as field delimiter in git pretty formats. * Unlike `|`, this character never appears in normal text (author names, * emails, commit messages), eliminating parse collisions. */ const FIELD_SEP = '\x1F' const RE_WHITESPACE = /\s+/ interface FrontmatterWithGitLog { git_log: Record<string, any> } function getFrontmatter(route: EditableTreeNode): FrontmatterWithGitLog { return route.meta.frontmatter as FrontmatterWithGitLog } /** Extract the login from a `https://github.com/<login>` URL. */ function extractLoginFromGithubUrl(url: string): string | undefined { return url.split('/').pop() || undefined } /** * Reconstruct an `email -> login` map from a previously written `git-log.json`. * Lets a committed cache seed GitHub resolution so already-known emails skip * the API entirely (only brand-new emails are queried). */ function buildKnownLoginsFromCache(prebuiltData: GitLogFileEntry): Map<string, string> { const knownLogins = new Map<string, string>() for (const entry of Object.values(prebuiltData)) { for (const contributor of entry.contributors || []) { if (!contributor.email || !contributor.github || knownLogins.has(contributor.email)) continue const login = extractLoginFromGithubUrl(contributor.github) if (login) knownLogins.set(contributor.email, login) } } return knownLogins } /** * Serialize an `email -> login` map to stable JSON (keys sorted, trailing * newline) so the committed cache produces minimal, conflict-free diffs. */ export function serializeLoginCache(logins: Record<string, string>): string { const sorted: Record<string, string> = {} for (const email of Object.keys(logins).sort()) sorted[email] = logins[email] return `${JSON.stringify(sorted, null, 2)}\n` } /** * Read the committed `email -> login` cache. Missing or malformed files * degrade gracefully to an empty map. */ async function readLoginCache(filePath: string): Promise<Record<string, string>> { try { if (await fs.pathExists(filePath)) return JSON.parse(await fs.readFile(filePath, 'utf-8')) } catch (error) { consola.error('valaxy-addon-git-log: Error reading GitHub login cache:', error) } return {} } /** * Merge freshly resolved logins into the existing cache and write it back. * Skips writing when there is nothing to persist. */ async function writeLoginCache( filePath: string, existing: Record<string, string>, contributors: Contributor[], ): Promise<void> { const merged: Record<string, string> = { ...existing } for (const contributor of contributors) { if (!contributor.github) continue const login = extractLoginFromGithubUrl(contributor.github) if (login) merged[contributor.email] = login } if (!Object.keys(merged).length) return try { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, serializeLoginCache(merged), 'utf-8') } catch (error) { consola.error('valaxy-addon-git-log: Error writing GitHub login cache at', filePath, error) } } export const destDir = path.resolve(process.cwd(), './public') // Only allow files from the user's working directory 'pages' folder export const currentWorkingDirectory = path.join(process.cwd(), 'pages') /** * basePath is resolved asynchronously via `git revparse`. Store the promise * so that callers can `await` it instead of racing against `setBasePath`. */ let basePathPromise: Promise<string> | undefined let basePath: string | undefined export function initBasePath() { basePathPromise = git.revparse(['--show-toplevel']).then((result) => { basePath = result.trim() return basePath }).catch((error) => { consola.warn('valaxy-addon-git-log: Could not resolve git root, falling back to cwd.', error) basePath = process.cwd() return basePath }) return basePathPromise } export async function ensureBasePath(): Promise<string> { if (basePath) return basePath if (basePathPromise) return basePathPromise return initBasePath() } /** @deprecated Use ensureBasePath() instead */ export function setBasePath(p: string) { basePath = p } export function getBasePath() { return basePath } /** * Pending route info collected during extendRoute, processed in batch later. */ interface PendingRoute { route: EditableTreeNode filePath: string gitRelativePath: string } let pendingRoutes: PendingRoute[] = [] /** * Collect route info during extendRoute (fast, no git calls). * Actual git operations are deferred to `flushGitLogBatch`. */ export async function handleGitLogInfo(options: GitLogOptions, route: EditableTreeNode) { const strategy = options.contributor?.strategy const isPrebuilt = strategy === 'prebuilt' const isBuildTime = strategy === 'build-time' const filePath = route.components.get('default') if (!filePath) return const frontmatter = getFrontmatter(route) if (!frontmatter.git_log) frontmatter.git_log = {} // Ensure basePath is available before computing relative path let resolvedBase: string try { resolvedBase = await ensureBasePath() } catch { // Fallback: use cwd-relative path resolvedBase = process.cwd() } const gitRelativePath = path.relative(resolvedBase, filePath).split(path.sep).join('/') frontmatter.git_log.path = gitRelativePath if (!isPrebuilt && !isBuildTime) return if (!filePath.startsWith(currentWorkingDirectory)) return pendingRoutes.push({ route, filePath, gitRelativePath }) } /** * Batch-fetch contributors for all files in a single git command. * Returns a map of filePath -> Contributor[]. */ async function batchGetContributors(resolvedBase: string, filePaths: string[], options?: GitLogOptions): Promise<Map<string, Contributor[]>> { const result = new Map<string, Contributor[]>() if (!filePaths.length) return result const { contributor } = options || {} try { const gitArgs = [ 'log', '--no-merges', `--pretty=format:---COMMIT_SEP---%an${FIELD_SEP}%ae`, '--name-only', ...(contributor?.logArgs?.trim() ? contributor.logArgs.trim().split(RE_WHITESPACE) : []), '--', ...filePaths, ] const raw = await git.raw(gitArgs) // Parse: each block is "---COMMIT_SEP---author\x1femail\nfile1\nfile2\n..." const blocks = raw.split('---COMMIT_SEP---').filter(Boolean) // fileContribMap: filePath -> { email -> Contributor } const fileContribMap = new Map<string, Record<string, Contributor>>() for (const block of blocks) { const lines = block.trim().split('\n') if (!lines.length) continue const [name, email] = lines[0].split(FIELD_SEP) if (!email) continue const files = lines.slice(1).filter(Boolean) for (const file of files) { // Resolve to absolute path for matching const absPath = path.resolve(resolvedBase, file) if (!fileContribMap.has(absPath)) fileContribMap.set(absPath, {}) const contribs = fileContribMap.get(absPath)! if (!contribs[email]) { contribs[email] = createContributor(name, email) } contribs[email].count++ } } for (const [fp, contribs] of fileContribMap) { const entries = Object.values(contribs) result.set(fp, deduplicateContributors(entries).sort((a, b) => b.count - a.count)) } } catch (e) { consola.error('valaxy-addon-git-log: Error batch-fetching contributors:', e) } return result } /** * Batch-fetch changelogs for all files in a single git command. * Returns a map of filePath -> Changelog[]. */ async function batchGetChangelog(resolvedBase: string, filePaths: string[], maxCount: number, options?: GitLogOptions): Promise<Map<string, Changelog[]>> { const result = new Map<string, Changelog[]>() if (!filePaths.length) return result try { // `git log --max-count` with multiple pathspecs limits the *global* commit // count, not per-file. We still need a global cap to keep the command // bounded on large repos — use `maxCount * filePaths.length` as a heuristic // upper bound. Each file's array is then truncated to `maxCount` in JS // below to preserve per-file semantics. const totalCap = Math.max(maxCount, maxCount * filePaths.length) const raw = await git.raw([ 'log', '--name-only', `--max-count=${totalCap}`, `--pretty=format:---CL_SEP---%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI${FIELD_SEP}%s`, '--', ...filePaths, ]) const blocks = raw.split('---CL_SEP---').filter(Boolean) for (const block of blocks) { const lines = block.trim().split('\n') if (!lines.length) continue const headerLine = lines[0] const [hash, authorName, authorEmail, date, ...rest] = headerLine.split(FIELD_SEP) const message = rest.join(FIELD_SEP) || '' if (!shouldIncludeCommit(message, options?.changelog)) continue const log: Changelog = { hash, date, message, refs: '', author_name: authorName, author_email: authorEmail, } if (message.includes('chore: release')) { log.version = message.split(' ')[2]?.trim() } const files = lines.slice(1).filter(Boolean) for (const file of files) { const absPath = path.resolve(resolvedBase, file) if (!result.has(absPath)) result.set(absPath, []) result.get(absPath)!.push(log) } } } catch (e) { consola.error('valaxy-addon-git-log: Error batch-fetching changelogs:', e) } // Truncate each file's changelog to maxCount to match per-file semantics for (const [fp, logs] of result) { if (logs.length > maxCount) result.set(fp, logs.slice(0, maxCount)) } return result } /** * Process all pending routes in batch: 2 git commands for ALL files * instead of 2 × N git commands (one per file). */ export async function flushGitLogBatch(options: GitLogOptions) { if (!pendingRoutes.length) return const routes = pendingRoutes pendingRoutes = [] const strategy = options.contributor?.strategy const isPrebuilt = strategy === 'prebuilt' const isBuildTime = strategy === 'build-time' let resolvedBase: string try { resolvedBase = await ensureBasePath() } catch (error) { consola.error('valaxy-addon-git-log: Failed to resolve git root in flushGitLogBatch, skipping.', error) return } const filePaths = routes.map(r => r.filePath) const maxCount = options.changelog?.maxCount ?? (process.env.CI ? 1000 : 100) // 2 git commands for ALL files (instead of 2 × N) const [contributorsMap, changelogMap] = await Promise.all([ batchGetContributors(resolvedBase, filePaths, options), batchGetChangelog(resolvedBase, filePaths, maxCount, options), ]) // Load the existing prebuilt cache up-front so we can both reuse previously // resolved GitHub logins (below) and merge per-file entries (later). const gitLogPath = isPrebuilt && destDir ? path.join(destDir, 'git-log.json') : undefined let prebuiltData: GitLogFileEntry = {} if (gitLogPath) { try { if (await fs.pathExists(gitLogPath)) prebuiltData = JSON.parse(await fs.readFile(gitLogPath, 'utf-8')) } catch (error) { consola.error('valaxy-addon-git-log: Error reading existing git log file:', error) } } // Resolve GitHub usernames for contributors without noreply emails. // Seed from both the dedicated login cache and any prior git-log.json so a // committed cache lets the build run with zero (or minimal) GitHub API calls. if (options.repositoryUrl && options.contributor?.resolveGitHub !== false) { const githubCachePath = options.contributor?.githubCache ? path.resolve(process.cwd(), options.contributor.githubCache) : undefined const cachedLogins = githubCachePath ? await readLoginCache(githubCachePath) : {} const knownLogins = buildKnownLoginsFromCache(prebuiltData) for (const [email, login] of Object.entries(cachedLogins)) { if (!knownLogins.has(email)) knownLogins.set(email, login) } const allContributors = [...new Set([...contributorsMap.values()].flat())] await resolveContributorsGitHub(allContributors, options.repositoryUrl, knownLogins) // Persist resolved logins back to the small, committable cache. if (githubCachePath) await writeLoginCache(githubCachePath, cachedLogins, allContributors) } for (const { route, filePath, gitRelativePath } of routes) { const contributors = contributorsMap.get(filePath) || [] const changeLog = changelogMap.get(filePath) || [] if (isBuildTime) { const frontmatter = getFrontmatter(route) frontmatter.git_log.contributors = contributors frontmatter.git_log.changeLog = changeLog } if (isPrebuilt) { prebuiltData[gitRelativePath] = { contributors, changeLog, path: gitRelativePath, } } } if (gitLogPath) { try { await fs.mkdir(path.dirname(gitLogPath), { recursive: true }) await fs.writeFile(gitLogPath, JSON.stringify(prebuiltData, null, 2), 'utf-8') } catch (error) { consola.error('valaxy-addon-git-log: Error writing git log file at', gitLogPath, error) } } }