mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-30 16:01:19 +00:00
* fix: create a symlink on non-elevated windows in docs test Windows terminals without Developer Mode or elevation cannot create file symlinks, so the versions publishing integration test failed with EPERM before it could reach the non-ordinary-file rejection. Fall back to a directory junction, which is creatable without elevation and hits the same rejection path. * fix: detect docs snapshot reparse points with lstat Classify entries with lstat instead of the readdir dirent so symlinks and Windows junctions are rejected regardless of how a runtime classifies them. This keeps the versions integration test's non-ordinary-file assertion meaningful on non-elevated Windows, where the fixture can only create a directory junction.
121 lines
4.2 KiB
JavaScript
121 lines
4.2 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { dirname, relative, resolve } from 'node:path';
|
|
|
|
export function createGit(repoRoot) {
|
|
return function git(args, options = {}) {
|
|
return execFileSync('git', args, {
|
|
cwd: repoRoot,
|
|
encoding: options.binary ? undefined : 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
};
|
|
}
|
|
|
|
export function resolveCommit(git, ref) {
|
|
return git(['rev-parse', `${ref}^{commit}`]).trim();
|
|
}
|
|
|
|
export function gitPathExists(git, ref, path) {
|
|
try {
|
|
git(['cat-file', '-e', `${ref}:${path}`]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function gitTreesEqual(git, ref, left, right) {
|
|
try {
|
|
git(['diff', '--quiet', `${ref}:${left}`, `${ref}:${right}`]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function listGitEntries(git, ref, root) {
|
|
const output = git(['ls-tree', '-r', '-z', ref, '--', root]);
|
|
return output
|
|
.split('\0')
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const separator = line.indexOf('\t');
|
|
const [mode, type, object] = line.slice(0, separator).split(' ');
|
|
return { mode, type, object, path: line.slice(separator + 1) };
|
|
});
|
|
}
|
|
|
|
export async function extractGitTree(git, ref, sourceRoot, destinationRoot) {
|
|
const entries = listGitEntries(git, ref, sourceRoot);
|
|
if (entries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`);
|
|
|
|
await rm(destinationRoot, { recursive: true, force: true });
|
|
for (const entry of entries) {
|
|
assertOrdinaryFile(entry, ref);
|
|
const relativePath = relative(sourceRoot, entry.path);
|
|
assertRelativePath(relativePath, entry.path);
|
|
const destination = resolve(destinationRoot, relativePath);
|
|
await mkdir(dirname(destination), { recursive: true });
|
|
await writeFile(destination, git(['cat-file', 'blob', entry.object], { binary: true }));
|
|
}
|
|
}
|
|
|
|
export async function compareGitTree(git, ref, sourceRoot, snapshotRoot) {
|
|
const expectedEntries = listGitEntries(git, ref, sourceRoot);
|
|
if (expectedEntries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`);
|
|
|
|
const expected = new Map();
|
|
for (const entry of expectedEntries) {
|
|
assertOrdinaryFile(entry, ref);
|
|
const relativePath = relative(sourceRoot, entry.path);
|
|
assertRelativePath(relativePath, entry.path);
|
|
expected.set(relativePath, entry);
|
|
}
|
|
const actualPaths = await listDocumentationPaths(snapshotRoot);
|
|
const expectedPaths = [...expected.keys()].sort();
|
|
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
|
|
throw new Error(`${snapshotRoot} file list differs from ${ref}:${sourceRoot}`);
|
|
}
|
|
|
|
for (const relativePath of expectedPaths) {
|
|
const entry = expected.get(relativePath);
|
|
const snapshotPath = resolve(snapshotRoot, relativePath);
|
|
const expectedContent = git(['cat-file', 'blob', entry.object], { binary: true });
|
|
if (!(await readFile(snapshotPath)).equals(expectedContent)) {
|
|
throw new Error(`${relativePath} differs from ${ref}:${entry.path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function listDocumentationPaths(root) {
|
|
const paths = [];
|
|
async function walk(directory) {
|
|
for (const name of await readdir(directory)) {
|
|
const path = resolve(directory, name);
|
|
// lstat does not follow links, so symlinks and Windows junctions stay
|
|
// non-ordinary here even if a runtime classifies them as directories in
|
|
// the readdir entry.
|
|
const stats = await lstat(path);
|
|
if (stats.isDirectory()) await walk(path);
|
|
else if (stats.isFile()) paths.push(relative(root, path));
|
|
else throw new Error(`${path} is not an ordinary documentation file`);
|
|
}
|
|
}
|
|
await walk(root);
|
|
return paths.sort();
|
|
}
|
|
|
|
function assertOrdinaryFile(entry, ref) {
|
|
if (entry.type !== 'blob' || entry.mode !== '100644') {
|
|
throw new Error(`${ref}:${entry.path} is not an ordinary documentation file`);
|
|
}
|
|
}
|
|
|
|
function assertRelativePath(relativePath, originalPath) {
|
|
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
throw new Error(`unsafe snapshot path ${originalPath}`);
|
|
}
|
|
}
|