valaxy-addon-git-log
Version:
Integrates git logs into your page of Valaxy site.
437 lines (371 loc) • 13.9 kB
text/typescript
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)
}
}
}