@astrojs/starlight
Version:
Build beautiful, high-performance documentation websites with Astro
122 lines (102 loc) • 3.58 kB
text/typescript
/**
* Git module to be used from the dev server and from the integration.
*/
import { basename, dirname, relative, resolve } from 'node:path';
import { realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
export type GitAPI = {
getNewestCommitDate: (file: string) => Date;
};
export const makeAPI = (directory: string): GitAPI => {
return {
getNewestCommitDate: (file) => getNewestCommitDate(resolve(directory, file)),
};
};
export function getNewestCommitDate(file: string): Date {
const result = spawnSync('git', ['log', '--format=%ct', '--max-count=1', basename(file)], {
cwd: dirname(file),
encoding: 'utf-8',
});
if (result.error) {
throw new Error(`Failed to retrieve the git history for file "${file}"`);
}
const output = result.stdout.trim();
const regex = /^(?<timestamp>\d+)$/;
const match = output.match(regex);
if (!match?.groups?.timestamp) {
throw new Error(`Failed to validate the timestamp for file "${file}"`);
}
const timestamp = Number(match.groups.timestamp);
const date = new Date(timestamp * 1000);
return date;
}
function getRepoRoot(directory: string): string {
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
cwd: directory,
encoding: 'utf-8',
});
if (result.error) {
return directory;
}
try {
return realpathSync(result.stdout.trim());
} catch {
return directory;
}
}
export function getAllNewestCommitDate(rootPath: string, docsPath: string): [string, number][] {
const repoRoot = getRepoRoot(docsPath);
const gitLog = spawnSync(
'git',
[
'log',
// Format each history entry as t:<seconds since epoch>
'--format=t:%ct',
// In each entry include the name and status for each modified file
'--name-status',
'--',
docsPath,
],
{
cwd: repoRoot,
encoding: 'utf-8',
// The default `maxBuffer` for `spawnSync` is 1024 * 1024 bytes, a.k.a 1 MB. In big projects,
// the full git history can be larger than this, so we increase this to ~10 MB. For example,
// Cloudflare passed 1 MB with ~4,800 pages and ~17,000 commits. If we get reports of others
// hitting ENOBUFS errors here in the future, we may want to switch to streaming the git log
// with `spawn` instead.
// See https://github.com/withastro/starlight/issues/3154
maxBuffer: 10 * 1024 * 1024,
}
);
if (gitLog.error) {
return [];
}
let runningDate = Date.now();
const latestDates = new Map<string, number>();
for (const logLine of gitLog.stdout.split('\n')) {
if (logLine.startsWith('t:')) {
// t:<seconds since epoch>
runningDate = Number.parseInt(logLine.slice(2)) * 1000;
}
// - Added files take the format `A\t<file>`
// - Modified files take the format `M\t<file>`
// - Deleted files take the format `D\t<file>`
// - Renamed files take the format `R<count>\t<old>\t<new>`
// - Copied files take the format `C<count>\t<old>\t<new>`
// The name of the file as of the commit being processed is always
// the last part of the log line.
const tabSplit = logLine.lastIndexOf('\t');
if (tabSplit === -1) continue;
const fileName = logLine.slice(tabSplit + 1);
const currentLatest = latestDates.get(fileName) || 0;
latestDates.set(fileName, Math.max(currentLatest, runningDate));
}
return Array.from(latestDates.entries()).map(([file, date]) => {
const fileFullPath = resolve(repoRoot, file);
let fileInDirectory = relative(rootPath, fileFullPath);
// Format path to unix style path.
fileInDirectory = fileInDirectory?.replace(/\\/g, '/');
return [fileInDirectory, date];
});
}