~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
185 lines · 5.3 KB Raw History
/**
 * Wrappers around the git CLI. Everything is captured; nothing may reach
 * stdout, which belongs to the remote-helper protocol.
 */

import { spawnSync } from 'node:child_process';

/**
 * @typedef {Object} GitResult
 * @property {number} status
 * @property {string} stdout
 * @property {string} stderr
 */

/**
 * @param {string[]} args
 * @returns {GitResult}
 */
export function git(args) {
  const result = spawnSync('git', args, {
    encoding: 'utf8',
    stdio: ['ignore', 'pipe', 'pipe'],
    maxBuffer: 64 * 1024 * 1024,
  });
  if (result.error) throw result.error;
  return {
    status: result.status ?? 1,
    stdout: result.stdout ?? '',
    stderr: result.stderr ?? '',
  };
}

/**
 * @param {string[]} args
 * @param {string} what - short description for the error message
 * @returns {string} trimmed stdout
 */
function gitOrThrow(args, what) {
  const result = git(args);
  if (result.status !== 0) {
    throw new Error(`git ${what} failed: ${result.stderr.trim()}`);
  }
  return result.stdout.trim();
}

/**
 * Resolve a rev to its object id without peeling, so an annotated tag ref
 * yields the tag object like a real git remote would advertise.
 * @param {string} rev
 * @returns {string|null} the object id, or null if the rev does not resolve
 */
export function revParse(rev) {
  const result = git(['rev-parse', '--verify', '--quiet', rev]);
  return result.status === 0 ? result.stdout.trim() : null;
}

/**
 * @param {string} sha
 * @returns {boolean}
 */
export function objectExists(sha) {
  return git(['cat-file', '-e', `${sha}^{object}`]).status === 0;
}

/**
 * One object's type and content from the local repository.
 * @param {string} sha
 * @returns {{type: string, data: Uint8Array}|null} null when the object is
 *   absent from the object database
 */
export function catObject(sha) {
  const typeResult = git(['cat-file', '-t', sha]);
  if (typeResult.status !== 0) return null;
  const type = typeResult.stdout.trim();
  const result = spawnSync('git', ['cat-file', type, sha], {
    stdio: ['ignore', 'pipe', 'pipe'],
    maxBuffer: 1024 * 1024 * 1024,
  });
  if (result.error) throw result.error;
  if (result.status !== 0) return null;
  return { type, data: new Uint8Array(result.stdout) };
}

/**
 * Object types for the given ids, resolved through one cat-file call. An
 * id absent from the object database is absent from the map.
 * @param {string[]} shas
 * @returns {Map<string, string>}
 */
export function objectTypes(shas) {
  if (shas.length === 0) return new Map();
  const result = spawnSync('git', ['cat-file', '--batch-check'], {
    encoding: 'utf8',
    input: `${shas.join('\n')}\n`,
    maxBuffer: 64 * 1024 * 1024,
  });
  if (result.error) throw result.error;
  /** @type {Map<string, string>} */
  const types = new Map();
  // One output line per input id, in order. A present object answers
  // "<oid> <type> <size>"; a missing one answers "<id> missing".
  const lines = (result.stdout ?? '').split('\n');
  shas.forEach((sha, i) => {
    const fields = (lines[i] ?? '').split(' ');
    if (fields.length === 3) types.set(sha, fields[1]);
  });
  return types;
}

/**
 * Reduce object ids to a subset with the same reachable closure:
 * merge-base --independent drops every commit another one reaches.
 * Non-commit ids (annotated tag objects) all stay, since no commit
 * reaches a tag object.
 * @param {string[]} shas
 * @returns {string[]}
 */
export function independentHeads(shas) {
  const types = objectTypes(shas);
  const commits = shas.filter((sha) => types.get(sha) === 'commit');
  const others = shas.filter((sha) => types.get(sha) !== 'commit');
  if (commits.length < 2) return [...commits, ...others];
  const independent = gitOrThrow(
    ['merge-base', '--independent', ...commits],
    'merge-base --independent',
  )
    .split('\n')
    .filter(Boolean);
  return [...independent, ...others];
}

/**
 * @param {string} ancestor
 * @param {string} descendant
 * @returns {boolean}
 */
export function isAncestor(ancestor, descendant) {
  return (
    git(['merge-base', '--is-ancestor', ancestor, descendant]).status === 0
  );
}

/**
 * Create a bundle at path containing the positives minus history reachable
 * from the negatives. Returns false when git refuses because the bundle
 * would be empty (everything already reachable from the negatives).
 * @param {string} path
 * @param {string[]} positives - ref names to include
 * @param {string[]} negatives - object ids to exclude
 * @returns {boolean} whether a bundle was written
 */
export function bundleCreate(path, positives, negatives) {
  const args = [
    'bundle',
    'create',
    path,
    ...negatives.map((sha) => `^${sha}`),
    ...positives,
  ];
  const result = git(args);
  if (result.status === 0) return true;
  if (/empty bundle/i.test(result.stderr)) return false;
  throw new Error(`git bundle create failed: ${result.stderr.trim()}`);
}

/**
 * Store a bundle's objects into the local repository's object database.
 * @param {string} path
 */
export function bundleUnbundle(path) {
  gitOrThrow(['bundle', 'unbundle', path], 'bundle unbundle');
}

/**
 * @param {string} ref
 * @param {string} sha
 */
export function updateRef(ref, sha) {
  gitOrThrow(['update-ref', ref, sha], 'update-ref');
}

/** @param {string} ref */
export function deleteRef(ref) {
  gitOrThrow(['update-ref', '-d', ref], 'update-ref -d');
}