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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
236 lines · 8.4 KB Raw History
/**
 * Assembling the push that merges a pull request.
 *
 * Merging is the authority's own act on the authority's own repository, so
 * this runs on that server under that account's session. It takes two object
 * stores, the canonical copy's and the copy holding the work, and answers with
 * a bundle to append and the ref to move. It writes nothing: the caller owns
 * the blobs and the record.
 *
 * The two stores are on different servers and neither holds the other's
 * objects, but a commit id is the same id everywhere, so the intersection of
 * two walks is the merge base. Where the base is main's own tip the result
 * tree is the branch's own. Where main has moved, the result is the
 * three-way merge of the two against the base, and a conflict refuses the
 * merge and names the paths.
 *
 * Workers-clean: no node imports.
 */

import { buildMergeCommit, mergeable, mergeTrailers } from './merge-commit.js';
import { mergeTrees } from './merge3.js';
import { parseCommit, parseTree } from './objects.js';
import { buildBundleIndex, encodePackIndex } from './pack-index.js';
import { writeBundle, writePack } from './pack-write.js';

/**
 * How far back main is read to place a branch against it. A branch taken
 * further back than this reads as unrelated, which is the honest answer from
 * a bounded walk rather than a guess.
 */
const MAIN_DEPTH = 1000;

/** How many commits of a branch are read before it counts as unrelated. */
const BRANCH_DEPTH = 500;

/**
 * What a bundle chain presents to a walk over it.
 * @typedef {Object} ObjectStore
 * @property {(sha: string) => {type: string, size: number}|null} meta
 * @property {(sha: string) => Promise<{type: string, data: Uint8Array}|null>} get
 */

/**
 * The commits one tip reaches, newest first, to a bounded depth.
 * @param {ObjectStore} store
 * @param {string} tip
 * @param {number} depth
 * @returns {Promise<string[]>}
 */
async function history(store, tip, depth) {
  /** @type {string[]} */
  const walked = [];
  const seen = new Set();
  let at = tip;
  while (at && walked.length < depth && !seen.has(at)) {
    seen.add(at);
    walked.push(at);
    const object = await store.get(at);
    if (object?.type !== 'commit') break;
    at = parseCommit(object.data).parents[0];
  }
  return walked;
}

/**
 * Every object a tree holds that the canonical copy lacks, the tree itself
 * included.
 *
 * A subtree whose id the canonical copy already has is skipped whole. Two
 * copies of one repository share almost every tree, so this is what keeps the
 * cost of a merge on the size of the change rather than the repository.
 * @param {Pick<ObjectStore, 'get'>} source
 * @param {ObjectStore} main
 * @param {string} root - a tree id
 * @param {Map<string, {type: string, data: Uint8Array}>} into
 */
async function collectTree(source, main, root, into) {
  if (main.meta(root) || into.has(root)) return;
  const tree = await source.get(root);
  if (!tree || tree.type !== 'tree') return;
  into.set(root, tree);
  for (const entry of parseTree(tree.data)) {
    // A submodule names a commit in another repository, which this one has
    // never held and is not merging now.
    if (entry.type === 'submodule') continue;
    if (main.meta(entry.sha) || into.has(entry.sha)) continue;
    if (entry.type === 'dir') {
      await collectTree(source, main, entry.sha, into);
      continue;
    }
    const blob = await source.get(entry.sha);
    if (blob) into.set(entry.sha, blob);
  }
}

/**
 * @typedef {Object} Merge
 * @property {string} sha - the commit that carries the work onto main
 * @property {string} ref - the canonical ref it moves
 * @property {Uint8Array} bundle
 * @property {Uint8Array} index - the pack index for that bundle
 * @property {string[]} heads - what the bundle carries, for the record entry
 * @property {number} objects - how many objects it adds
 */

/**
 * Build the push that merges one pull request.
 *
 * @param {Object} ctx
 * @param {ObjectStore} ctx.main - the canonical copy's objects
 * @param {ObjectStore} ctx.source - the objects of the copy holding the work
 * @param {string} ctx.mainTip
 * @param {string} ctx.branchTip
 * @param {string} ctx.canonicalRef - full ref name main lives under
 * @param {'merge'|'squash'} ctx.mode
 * @param {{did: string, repo: string, ref: string}} ctx.from - the copy and
 *   branch the work came from, for the trailers
 * @param {{name: string, email: string}} ctx.by - who is merging it
 * @param {number} ctx.at - epoch milliseconds
 * @param {string} ctx.subject
 * @param {string} [ctx.body]
 * @returns {Promise<Merge>}
 */
export async function buildMerge(ctx) {
  const { main, source, mainTip, branchTip, canonicalRef, mode, from } = ctx;

  // Asked before the walk, so a ref pointing at a tag or a tree is named for
  // what it is rather than reported as sharing no history with main.
  const tip = await source.get(branchTip);
  if (tip?.type !== 'commit') {
    throw new Error('That branch does not end in a commit.');
  }
  const branchTree = parseCommit(tip.data).tree;

  const inMain = new Set(await history(main, mainTip, MAIN_DEPTH));
  const branch = await history(source, branchTip, BRANCH_DEPTH);
  const base = branch.find((sha) => inMain.has(sha)) ?? null;

  const answer = mergeable({ base, sha: branchTip }, mainTip);
  if (!answer.can) throw new Error(answer.why);

  /** @type {Map<string, {type: string, data: Uint8Array}>} */
  const objects = new Map();

  // Main has moved: the result is what the two sides come to against the
  // base. The objects the merge makes are new to both copies and travel.
  let tree = branchTree;
  if (base !== mainTip) {
    const [baseCommit, mainCommit] = await Promise.all([
      main.get(/** @type {string} */ (base)),
      main.get(mainTip),
    ]);
    if (!baseCommit || !mainCommit) {
      throw new Error('Main could not be read for the merge.');
    }
    const merged = await mergeTrees(
      { get: async (sha) => (await main.get(sha)) ?? (await source.get(sha)) },
      parseCommit(baseCommit.data).tree,
      parseCommit(mainCommit.data).tree,
      branchTree,
      {
        labels: {
          ours: canonicalRef.replace(/^refs\/heads\//, ''),
          theirs: from.ref.replace(/^refs\/heads\//, ''),
        },
      },
    );
    if (merged.conflicts.length > 0) {
      throw new Error(
        `This branch conflicts with main in ${merged.conflicts.join(', ')}.`,
      );
    }
    for (const [sha, object] of merged.objects) {
      if (!main.meta(sha)) objects.set(sha, object);
    }
    tree = merged.sha;
  }
  // A walk over the result reads what the merge made before what the branch
  // holds, so a merged subtree opens and its unchanged children come from
  // the branch's copy.
  const made = {
    /** @param {string} sha */
    get: async (sha) => objects.get(sha) ?? (await source.get(sha)),
  };
  // A merge names the branch tip as a parent, so main's history has to reach
  // every commit of the branch, and a walk over that history opens each one's
  // own tree. The tip's tree alone is not enough: git reads every commit it
  // can reach, and a tree that is not there fails the whole fetch.
  //
  // A squash names only main, so the commits it squashed are unreachable and
  // stay where they are. Only the tree the result points at travels.
  if (mode === 'merge') {
    for (const sha of branch) {
      if (sha === base || main.meta(sha)) break;
      const commit = await source.get(sha);
      if (!commit) continue;
      objects.set(sha, commit);
      await collectTree(source, main, parseCommit(commit.data).tree, objects);
    }
  }
  await collectTree(made, main, tree, objects);

  const commit = buildMergeCommit({
    mode,
    tree,
    mainTip,
    branchTip,
    by: ctx.by,
    at: ctx.at,
    subject: ctx.subject,
    body: ctx.body,
    trailers: mergeTrailers(from),
  });

  const pack = writePack([...objects.values(), commit]);
  const bundle = writeBundle({
    refs: [{ sha: commit.sha, name: canonicalRef }],
    // What the reader must already hold. Main's tip covers the canonical
    // history; a merge also assumes nothing else, because every object the
    // branch adds is in the pack above.
    prerequisites: [mainTip],
    pack,
  });
  // No delta base to resolve: writePack stores every object whole, so nothing
  // in this pack refers outside it.
  const index = encodePackIndex(await buildBundleIndex(bundle));

  return {
    sha: commit.sha,
    ref: canonicalRef,
    bundle,
    index,
    heads: [commit.sha],
    objects: objects.size + 1,
  };
}