You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vitepress/src/node/utils/getGitTimestamp.ts

243 lines
6.3 KiB

import { spawn, sync } from 'cross-spawn'
import { once } from 'node:events'
import fs from 'node:fs'
import path from 'node:path'
import { Transform, type TransformCallback } from 'node:stream'
import { createDebug } from 'obug'
import { slash } from '../shared'
const debug = createDebug('vitepress:git')
const cache = new Map<string, number>()
const authoritativeRoots = new Set<string>()
const RS = 0x1e
const NUL = 0x00
const LF = 0x0a
interface GitLogRecord {
ts: number
files: string[]
}
type State = 'READ_TS' | 'READ_FILE'
class GitLogParser extends Transform {
#state: State = 'READ_TS'
#tsBytes: number[] = []
#fileBytes: number[] = []
#files: string[] = []
constructor() {
super({ readableObjectMode: true })
}
override _transform(
chunk: Buffer,
_enc: BufferEncoding,
cb: TransformCallback
): void {
try {
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i] === LF ? NUL : chunk[i] // treat LF as NUL
switch (this.#state) {
case 'READ_TS': {
if (b === RS) {
// ignore
} else if (b === NUL) {
this.#state = 'READ_FILE'
} else {
this.#tsBytes.push(b)
}
break
}
case 'READ_FILE': {
if (b === RS) {
this.#emitRecord()
} else if (b === NUL) {
if (this.#fileBytes.length > 0) {
this.#files.push(Buffer.from(this.#fileBytes).toString('utf8'))
this.#fileBytes.length = 0
}
} else {
this.#fileBytes.push(b)
}
break
}
}
}
cb()
} catch (err) {
cb(err as Error)
}
}
override _flush(cb: TransformCallback): void {
try {
if (this.#state === 'READ_FILE') {
if (this.#fileBytes.length > 0) {
throw new Error('GitLogParser: unexpected EOF while reading filename')
} else {
this.#emitRecord()
}
}
cb()
} catch (err) {
cb(err as Error)
}
}
#emitRecord(): void {
const ts = Buffer.from(this.#tsBytes).toString('utf8')
const rec: GitLogRecord = {
ts: Number.parseInt(ts, 10) * 1000,
files: this.#files.slice()
}
if (rec.ts > 0 && rec.files.length > 0) this.push(rec)
this.#tsBytes.length = 0
this.#fileBytes.length = 0
this.#files.length = 0
this.#state = 'READ_TS'
}
}
export async function cacheAllGitTimestamps(
root: string,
pathspec: string[] = ['*.md'],
cacheMissing = false
): Promise<void> {
const cp = sync('git', ['rev-parse', '--show-toplevel'], { cwd: root })
if (cp.error) throw cp.error
if (cp.status !== 0) {
throw new Error(
`git rev-parse failed (${cp.signal ? `signal ${cp.signal}` : `exit ${cp.status}`}): ${cp.stderr.toString('utf8').trim()}`
)
}
const gitRoot = slash(path.resolve(cp.stdout.toString('utf8').trim()))
const head = sync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
cwd: root
})
if (head.error) throw head.error
if (head.status !== 0) {
publishGitCache(gitRoot, new Map(), cacheMissing)
return
}
const args = [
'log',
'--pretty=format:%x1e%at%x00', // RS + epoch + NUL
'--name-only',
'-z',
'--',
...pathspec
]
const child = spawn('git', args, { cwd: root })
const records = child.stdout.pipe(new GitLogParser())
child.on('error', (err) => records.destroy(err))
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => {
stderr += chunk
})
const close = once(child, 'close') as Promise<
[code: number | null, signal: NodeJS.Signals | null]
>
const nextCache = new Map<string, number>()
for await (const rec of records as AsyncIterable<GitLogRecord>) {
for (const file of rec.files) {
const slashed = slash(path.resolve(gitRoot, file))
if (!nextCache.has(slashed)) nextCache.set(slashed, rec.ts)
}
}
const [code, signal] = await close
if (code !== 0) {
throw new Error(
`git log failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${stderr.trim()}`
)
}
// Publish a complete repository snapshot atomically. Other repository
// caches stay valid for concurrent/sequential multi-site builds.
publishGitCache(gitRoot, nextCache, cacheMissing)
}
export async function getGitTimestamp(file: string): Promise<number> {
const normalizedFile = slash(path.resolve(file))
const cached = cache.get(normalizedFile)
if (cached !== undefined) return cached
// A production pre-scan is authoritative for the repository history. Files
// missing from it are untracked (or have no commits), so spawning one git
// process per miss only repeats work and is especially costly for generated
// dynamic routes.
if (
[...authoritativeRoots].some((root) => isWithinRoot(normalizedFile, root))
) {
cache.set(normalizedFile, 0)
return 0
}
// most likely will never happen except for recently added files in dev
debug(`[cache miss] ${file}`)
if (!fs.existsSync(file)) {
return 0
}
const head = sync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
cwd: path.dirname(file)
})
if (head.error) throw head.error
if (head.status !== 0) return 0
const child = spawn(
'git',
['log', '-1', '--pretty=%at', '--', path.basename(file)],
{ cwd: path.dirname(file) }
)
let output = ''
child.stdout.on('data', (d) => (output += String(d)))
const [code, signal] = (await once(child, 'close')) as [
number | null,
NodeJS.Signals | null
]
if (code !== 0) {
throw new Error(
`git log failed for ${file} (${signal ? `signal ${signal}` : `exit ${code}`}).`
)
}
const ts = Number.parseInt(output.trim(), 10) * 1000
if (!(ts > 0)) {
return 0
}
cache.set(normalizedFile, ts)
return ts
}
function isWithinRoot(file: string, root: string): boolean {
return file === root || file.startsWith(`${root}/`)
}
function publishGitCache(
gitRoot: string,
nextCache: ReadonlyMap<string, number>,
cacheMissing: boolean
): void {
for (const file of cache.keys()) {
if (isWithinRoot(file, gitRoot)) cache.delete(file)
}
for (const [file, timestamp] of nextCache) cache.set(file, timestamp)
if (cacheMissing) authoritativeRoots.add(gitRoot)
else authoritativeRoots.delete(gitRoot)
}