How change requests merge block by block
A look inside Docsheaf's three-way merge — content-addressed documents, field-by-field page merges and diff3 over blocks — and why two people can edit the same page without stepping on each other.
The Docsheaf team · · 3 min read
When two writers edit the same page in a wiki, the second save usually wins. In Docsheaf, both edits survive — as long as they touched different blocks. This post explains how.
Branches are cheap because documents are immutable#
A page body is a ProseMirror-style JSON document: a list of blocks such as paragraphs, headings, code blocks and hints. Docsheaf stores each document content-addressed: its id is the SHA-256 hash of its canonical JSON.
That one decision makes branching cheap. A branch is a working tree of page rows — title, slug, position, parent and a pointer to a document. Opening a change request copies those rows, not the content. Pages nobody edits keep pointing at the same document, on every branch and in every revision.
Revisions are immutable snapshots of a tree. Docsheaf creates one when a change request opens (its base), on every merge, and at most every ten minutes for live edits on main.
Three trees, not two#
A merge compares three versions of the space:
base — the revision the change request started from
ours — main as it is now
theirs — the change request branch
Comparing each side against the common base is what lets Docsheaf tell "you changed this" apart from "they changed this". A two-way diff can't.
Step 1: merge each page field by field#
For every page that exists in any of the three trees, Docsheaf compares each field — title, slug, emoji, description, parent, position, visibility and so on — against the base:
Changed on one side only: take that side.
Changed identically on both sides: fine, take it.
Changed differently on both sides: a conflict for that page.
The one exception is the page's document. Different documents on both sides don't fail yet — they fall through to a block-level merge.
Step 2: diff3 over blocks#
When both sides edited the same page, Docsheaf runs a three-way merge over its top-level blocks. Each block is keyed by its canonical JSON, so "the same block" means byte-for-byte identical content.
Compute the changes (hunks) from base to ours and from base to theirs.
Group hunks that overlap in the base.
A group touched by only one side applies cleanly.
A group touched by both sides merges only if both produce identical content; otherwise the page conflicts.
In practice: if one writer fixes a typo in the introduction and another rewrites the troubleshooting section further down, both changes land. If both rewrite the same paragraph differently, Docsheaf stops and asks.
Step 3: additions, deletions and tidy-up#
A page added on one side is kept.
A page deleted on one side and untouched on the other is deleted.
A page deleted on one side and modified on the other is a conflict — silently dropping someone's edit is the worst outcome.
Finally, pages whose parent disappeared are moved to the root, and sibling positions are normalised.
Resolving conflicts#
Conflicts block the merge. The API answers 409 merge_conflict with the list of affected pages and why each one conflicts. The reviewer picks a side per page — main or the change request — and merges again with those resolutions.
{
"resolutions": {
"pg_8fK2mQx1Lr0aVb7c": "theirs",
"pg_Tz4nW9sYp3eHd6Ju": "ours"
}
}Update from main uses the same machinery in the other direction: it merges main into the change request branch and moves the base forward, so long-running change requests can catch up before review.
Saves that never overwrite silently#
Merging is one half; saving is the other. Every page row carries a version number. The editor sends the version it started from with each save, and if someone else saved in between, the API answers 409 version_conflict instead of overwriting their work.
Why blocks and not lines?#
Docs aren't code. A paragraph reflowed by the editor would show up as a dozen changed lines in a text diff. Blocks match how writers think about a page — this hint, that code sample, the table below it — so merges and review diffs line up with what actually changed.
The merge lives in @docsheaf/document, the same package the editor and the published site use, and it is covered by tests like any other code path that can lose work.