aboutsummaryrefslogtreecommitdiff
path: root/scripts/docs/snapshot.mjs
blob: 7610715a939c55cf697b67656e2a07d85ddf2dda (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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}`);
  }
}