// 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} 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 1756000000 +0000', 'committer Bob 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/); }); });