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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
298 lines · 9.7 KB Raw History
// Assembling the push that merges a pull request, over two object stores that
// hold what two servers would. The bundle it answers with is read back by the
// parsers that read git's own, so a merge that indexes here indexes there.

import { describe, expect, it } from 'vitest';
import { parseBundle } from '../src/bundle.js';
import { buildMerge } from '../src/merge.js';
import {
  indexPacks,
  objectId,
  parseCommit,
  parseTree,
} from '../src/objects.js';

const encoder = new TextEncoder();

/** @param {string} type @param {Uint8Array} data */
const object = (type, data) => ({ type, data, sha: objectId(type, data) });

/** @param {string} text */
const blob = (text) => object('blob', encoder.encode(text));

/**
 * A tree of files, in git's own entry format.
 * @param {Record<string, {sha: string, type?: string}>} files
 */
function tree(files) {
  /** @type {Uint8Array[]} */
  const parts = [];
  for (const [name, entry] of Object.entries(files)) {
    const mode = entry.type === 'tree' ? '40000' : '100644';
    const prefix = encoder.encode(`${mode} ${name}\0`);
    const raw = Uint8Array.from(
      (entry.sha.match(/../g) ?? []).map((/** @type {string} */ pair) =>
        Number.parseInt(pair, 16),
      ),
    );
    const row = new Uint8Array(prefix.length + raw.length);
    row.set(prefix, 0);
    row.set(raw, prefix.length);
    parts.push(row);
  }
  const size = parts.reduce((total, part) => total + part.length, 0);
  const data = new Uint8Array(size);
  let at = 0;
  for (const part of parts) {
    data.set(part, at);
    at += part.length;
  }
  return object('tree', data);
}

/** @param {string} treeSha @param {string[]} parents @param {string} message */
function commit(treeSha, parents, message) {
  return object(
    'commit',
    encoder.encode(
      [
        `tree ${treeSha}`,
        ...parents.map((parent) => `parent ${parent}`),
        'author Bob <bob@example.com> 1756000000 +0000',
        'committer Bob <bob@example.com> 1756000000 +0000',
        '',
        `${message}\n`,
      ].join('\n'),
    ),
  );
}

/**
 * An object store over a plain map, the shape a bundle chain presents.
 * @param {Array<{sha: string, type: string, data: Uint8Array}>} objects
 */
function storeOf(objects) {
  const held = new Map(objects.map((one) => [one.sha, one]));
  return {
    /** @param {string} sha */
    meta: (sha) => {
      const one = held.get(sha);
      return one ? { type: one.type, size: one.data.length } : null;
    },
    /** @param {string} sha */
    get: async (sha) => held.get(sha) ?? null,
  };
}

// One root commit both copies share, then one commit only bob has.
const readme = blob('# demo\n');
const rootTree = tree({ 'README.md': readme });
const root = commit(rootTree.sha, [], 'the first commit');

const added = blob('export const farewell = 1;\n');
const workTree = tree({ 'README.md': readme, 'index.js': added });
const work = commit(workTree.sha, [root.sha], 'add farewell()');

// A second commit on the branch, so the branch has a commit whose tree is not
// the tip's. A merge has to carry that tree too: main's history reaches the
// commit, and git opens every tree it can reach.
const midway = blob('half done\n');
const midTree = tree({ 'README.md': readme, 'notes.md': midway });
const shared = [readme, rootTree, root];
const main = storeOf(shared);
const source = storeOf([...shared, added, workTree, work]);

// bob's branch as two commits: the notes, then farewell() on top.
const midRooted = commit(midTree.sha, [root.sha], 'start the notes');
const stackedTip = commit(workTree.sha, [midRooted.sha], 'add farewell()');
const stackedSource = storeOf([
  ...shared,
  added,
  workTree,
  midway,
  midTree,
  midRooted,
  stackedTip,
]);

const from = {
  did: 'did:plc:bob',
  repo: 'demo',
  ref: 'refs/heads/agent/farewell',
};
const by = { name: 'Alice', email: 'alice@example.com' };

/** @param {{mode: 'merge'|'squash'}} how */
const merge = ({ mode }) =>
  buildMerge({
    main,
    source,
    mainTip: root.sha,
    branchTip: work.sha,
    canonicalRef: 'refs/heads/main',
    mode,
    from,
    by,
    at: Date.UTC(2026, 7, 24),
    subject: 'add farewell()',
  });

describe('buildMerge', () => {
  it('bundles what the canonical copy lacks, and nothing it has', async () => {
    const built = await merge({ mode: 'merge' });
    const objects = await indexPacks([parseBundle(built.bundle).pack]);

    // The commit, its tree, the file it added, and the merge itself. The
    // README and the root tree are already there and are not sent again.
    expect([...objects.keys()].sort()).toEqual(
      [work.sha, workTree.sha, added.sha, built.sha].sort(),
    );
    expect(objects.has(readme.sha)).toBe(false);
    expect(objects.has(rootTree.sha)).toBe(false);
  });

  it('carries the tree of every commit a merge makes reachable', async () => {
    const built = await buildMerge({
      main,
      source: stackedSource,
      mainTip: root.sha,
      branchTip: stackedTip.sha,
      canonicalRef: 'refs/heads/main',
      mode: 'merge',
      from,
      by,
      at: 0,
      subject: 'add farewell()',
    });
    const objects = await indexPacks([parseBundle(built.bundle).pack]);

    // The middle commit rides along, so the tree it points at has to as well:
    // git reads every commit the merge reaches and opens each one's tree.
    expect(objects.has(midRooted.sha)).toBe(true);
    expect(objects.has(midTree.sha)).toBe(true);
    expect(objects.has(midway.sha)).toBe(true);
  });

  it('gives a merge the branch tip as a parent and its tree as the result', async () => {
    const built = await merge({ mode: 'merge' });
    const objects = await indexPacks([parseBundle(built.bundle).pack]);
    const merged = parseCommit(
      /** @type {{data: Uint8Array}} */ (objects.get(built.sha)).data,
    );
    expect(merged.parents).toEqual([root.sha, work.sha]);
    expect(merged.tree).toBe(workTree.sha);
  });

  it('leaves a squash the commits it squashed', async () => {
    const built = await merge({ mode: 'squash' });
    const objects = await indexPacks([parseBundle(built.bundle).pack]);
    expect(objects.has(work.sha)).toBe(false);
    const squashed = parseCommit(
      /** @type {{data: Uint8Array}} */ (objects.get(built.sha)).data,
    );
    expect(squashed.parents).toEqual([root.sha]);
    // The graph says nothing, so the message carries the answer.
    expect(
      new TextDecoder().decode(
        /** @type {{data: Uint8Array}} */ (objects.get(built.sha)).data,
      ),
    ).toContain('Merged-ref: refs/heads/agent/farewell');
  });

  it('names the ref it moves, and what a reader must already hold', async () => {
    const built = await merge({ mode: 'merge' });
    const parsed = parseBundle(built.bundle);
    expect(parsed.refs).toEqual([{ sha: built.sha, name: 'refs/heads/main' }]);
    expect(parsed.prerequisites).toEqual([root.sha]);
    expect(built.index.length).toBeGreaterThan(0);
  });

  it('merges a branch main has moved under, when the two sides are apart', async () => {
    // Main added notes; the branch added index.js. Both land.
    const notes = blob('notes\n');
    const movedTree = tree({ 'README.md': readme, 'NOTES.md': notes });
    const moved = commit(movedTree.sha, [root.sha], 'main moved on');
    const movedMain = storeOf([...shared, notes, movedTree, moved]);
    const answer = await buildMerge({
      main: movedMain,
      source,
      mainTip: moved.sha,
      branchTip: work.sha,
      canonicalRef: 'refs/heads/main',
      from,
      by,
      at: 0,
      subject: 'add farewell()',
      mode: 'merge',
    });
    const objects = await indexPacks([parseBundle(answer.bundle).pack]);
    const result = parseCommit(
      /** @type {{data: Uint8Array}} */ (objects.get(answer.sha)).data,
    );
    expect(result.parents).toEqual([moved.sha, work.sha]);
    const merged = objects.get(result.tree);
    expect(merged).toBeDefined();
    const names = parseTree(
      /** @type {{data: Uint8Array}} */ (merged).data,
    ).map((entry) => entry.name);
    expect(names).toEqual(['NOTES.md', 'README.md', 'index.js']);
  });

  it('refuses a branch that conflicts with where main has moved, naming the path', async () => {
    const theirs = blob('# demo\nbranch\n');
    const ours = blob('# demo\nmain\n');
    const clashTree = tree({ 'README.md': theirs });
    const clash = commit(clashTree.sha, [root.sha], 'rewrite the readme');
    const movedTree = tree({ 'README.md': ours });
    const moved = commit(movedTree.sha, [root.sha], 'main rewrites it too');
    await expect(
      buildMerge({
        main: storeOf([...shared, ours, movedTree, moved]),
        source: storeOf([...shared, theirs, clashTree, clash]),
        mainTip: moved.sha,
        branchTip: clash.sha,
        canonicalRef: 'refs/heads/main',
        from,
        by,
        at: 0,
        subject: 'rewrite the readme',
        mode: 'merge',
      }),
    ).rejects.toThrow(/conflicts with main in README\.md/);
  });

  it('refuses a ref that does not end in a commit', async () => {
    await expect(
      buildMerge({
        main,
        source,
        mainTip: root.sha,
        branchTip: workTree.sha,
        canonicalRef: 'refs/heads/main',
        mode: 'merge',
        from,
        by,
        at: 0,
        subject: 'x',
      }),
    ).rejects.toThrow(/does not end in a commit/);
  });

  it('refuses a branch sharing no history with main', async () => {
    const alone = commit(rootTree.sha, [], 'another history');
    await expect(
      buildMerge({
        main,
        source: storeOf([...shared, alone]),
        mainTip: root.sha,
        branchTip: alone.sha,
        canonicalRef: 'refs/heads/main',
        from,
        by,
        at: 0,
        subject: 'x',
        mode: 'merge',
      }),
    ).rejects.toThrow(/no history/);
  });
});