chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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');
}