/** * Merging a pull request: the commit that puts a branch's work on main. * * Two modes, and the difference is what main's history keeps. * * A merge commit names the branch tip as its second parent, so the graph * itself says where the work came from and a reader recovers the whole pull * request from it later. A squash names only main, so the graph says nothing, * and the commit carries trailers naming the copy and the ref instead. That * is the decentralised form of a forge's "(#123)": a forge recovers a squashed * pull request from a number that a central authority minted, and no such * authority exists here. * * Both modes require the branch to be up to date with main. Then the merge is * trivial and the result tree is the branch's own, with no three-way content * merge to do and nothing to get wrong. A branch behind main is refused, and * rebasing it is the author's move. * * Workers-clean: no node imports. */ import { objectId } from './objects.js'; const encoder = new TextEncoder(); /** * Whether a branch has anything to merge. What the merge comes to when main * has moved is the three-way merge's answer, not this one's. * * `base` is the merge base of the branch and main. A base that is the * branch's own tip means main already reaches all of it. * @param {{base: string|null, sha: string}} pull * @param {string} mainTip * @returns {{can: boolean, why: string}} */ export function mergeable(pull, mainTip) { if (pull.sha === mainTip) { return { can: false, why: 'This is already the tip of main.' }; } if (!pull.base) { return { can: false, why: 'This branch shares no history with main.' }; } if (pull.base === pull.sha) { return { can: false, why: 'Main already reaches this branch.' }; } return { can: true, why: '' }; } /** * The trailers a merged commit carries, naming the work it came from. * * A merge commit carries them too. The graph already says where the work came * from, and the trailers say it in the vocabulary a reader of the record * speaks: an account and a ref rather than a commit id. * @param {Object} from * @param {string} from.did - the account whose copy held the work * @param {string} from.repo - the record key of their copy * @param {string} from.ref - full ref name of their branch * @returns {string[]} */ export function mergeTrailers({ did, repo, ref }) { return [ `Merged-from: at://${did}/dev.pdsjs.git.repo/${repo}`, `Merged-ref: ${ref}`, ]; } /** * One git identity line's worth of a person, as a commit records them. * @param {{name: string, email: string}} who * @param {number} at - epoch milliseconds * @returns {string} */ function stamp(who, at) { return `${who.name} <${who.email}> ${Math.floor(at / 1000)} +0000`; } /** * The commit that merges a pull request onto main. * * @param {Object} merge * @param {'merge'|'squash'} merge.mode * @param {string} merge.tree - the branch tip's tree, which is the result * @param {string} merge.mainTip * @param {string} merge.branchTip * @param {{name: string, email: string}} merge.by - who is merging it * @param {number} merge.at - epoch milliseconds * @param {string} merge.subject - the first line * @param {string} [merge.body] * @param {string[]} [merge.trailers] * @returns {{sha: string, type: string, data: Uint8Array}} */ export function buildMergeCommit({ mode, tree, mainTip, branchTip, by, at, subject, body = '', trailers = [], }) { const parents = mode === 'merge' ? [mainTip, branchTip] : [mainTip]; const who = stamp(by, at); // Subject, body and trailer block, each divided from the next by one blank // line. A section with nothing in it leaves no blank line behind it. const message = [subject, body.trim(), trailers.join('\n')] .filter((part) => part !== '') .join('\n\n'); const data = encoder.encode( [ `tree ${tree}`, ...parents.map((parent) => `parent ${parent}`), `author ${who}`, `committer ${who}`, '', `${message}\n`, ].join('\n'), ); return { sha: objectId('commit', data), type: 'commit', data }; }