Two writers, one Markdown file: collaboration without a sync engine
Most collaborative editors answer “what if two people edit the same thing?” with a sync engine: a server that owns the document, an operation log, and a convergence algorithm — OT or a CRDT — merging keystrokes in real time. DocuCommit can’t use that answer. Not because it would be hard to build, but because the storage model excludes it. This is what replaces it, and what that costs.
The constraint that decides everything
Two invariants sit at the top of the architecture and never move: the DocuCommit
server (read-only web app) never writes to Git, and Git writes happen only in the
DocuCommit editor (desktop app), only on explicit user action — never in the
background, never on a timer. The canonical store is a directory of .md files in
a repository you own, with no serialization layer between your content and the
files.
Real-time co-editing needs the opposite. A CRDT or an OT server holds authoritative mutable state and broadcasts it continuously; the file becomes an export of that state. You can’t have “the file is the truth” and “a daemon rewrites the file while you type” at once.
Concurrency is handled in two places instead: compare-on-write, when two processes touch one file in one checkout, and a three-way merge, when two checkouts diverge and get pulled back together. The honest cost is that you see no cursors and learn about a collision only when you save or press Get updates. In exchange, nothing runs between you and your files.
Layer 1: the save race on one checkout
When the editor opens a document, EditorApiController returns the editor HTML
along with a baselineHash — LocalEditService.currentBlobHash(...), which is the
Git blob object ID of the file’s bytes, computed with
new ObjectInserter.Formatter().idFor(Constants.OBJ_BLOB, bytes). The client holds
it for the whole edit session and sends it back with the save.
The check in LocalEditService.save is one line:
if (!force && baselineHash != null && !baselineHash.isBlank()
&& !blobHash(resolved).equals(baselineHash)) {
return SaveOutcome.CHANGED_ON_DISK;
}
If the file is gone entirely the outcome is MOVED_OR_DELETED. A successful save
returns a freshly recomputed baselineHash, so the session rolls forward — the
write canonicalizes the Markdown, so the bytes on disk are not the bytes the client
sent.
CHANGED_ON_DISK becomes the “This page changed while you were editing” dialog:
keep editing, discard yours and reload, or overwrite with force: true. One whole
version wins, deliberately — the other side is usually a machine (a pull, a script,
another checkout), and there is no useful ancestor to merge against.
Why a hash and not a modification time. mtime answers “was this file written?” The question you have is “are the bytes still the ones I opened?” Those differ constantly: a pull can rewrite a file back to identical content, a restore bumps timestamps on everything, granularity is coarse. A content hash has neither false positives nor false negatives, and the Git blob OID is the identity Git already uses for the index and for history.
Why not a lock. Locking needs a coordinator, and a coordinator needs a policy for
stale locks — a distributed-systems problem grafted onto a text editor. It also
wouldn’t help, because the other writer is often git merge or a script.
The path is end-to-end tested: e2e/tests/edit/edit.spec.js opens a document,
rewrites the file behind the editor’s back with fs.writeFileSync, types, saves,
asserts the dialog appears carrying the on-disk text, and asserts that Overwrite
with mine wins on disk.
Layer 2: between two checkouts
Get updates fetches and then runs a real merge — MergeStrategy.RECURSIVE,
setCommit(true) — not a fast-forward-only pull, not a rebase, not a “remote wins”
overwrite. When Git can’t reconcile a path it leaves unmerged entries in the index,
and GitWorkspace.conflicts() reads them out of the DirCache: skip stage 0, group
the rest by path, decode as UTF-8, and you have stage 1 (base), 2 (ours), 3 (theirs).
Which stages are present is the classification, in kindOf:
| Stages | Kind | What it means for a document |
|---|---|---|
| 1, 2, 3 | BOTH_MODIFIED | You both edited overlapping regions of the page |
| 1, 3 (no 2) | DELETE_MODIFY | You deleted the page; they edited it |
| 1, 2 (no 3) | MODIFY_DELETE | They deleted the page; you edited it |
| no base | ADD_ADD | You both created a different page at the same path |
ADD_ADD is the one to understand: with no common ancestor there is nothing to diff
against, so every line reads as a clash. Not a bug — an accurate description of the
situation.
Those three strings go into ThreeWayMerge, a thin layer over JGit’s
MergeAlgorithm. It merges three RawText sequences with
RawTextComparator.DEFAULT and walks the resulting MergeChunks.
ConflictState.NO_CONFLICT chunks become MergeSegment.Stable; conflicting ranges
accumulate into a String[3] indexed by sequence and close into a
MergeSegment.Clash(base, ours, theirs) at the next FIRST_CONFLICTING_RANGE or
stable chunk. Adjacent stable segments get coalesced.
The point of that walk is what it avoids producing. Git’s <<<<<<< markers are a
serialization of exactly this structure for a terminal; emitting them and re-parsing
them for a GUI is a lossy round trip. Keeping the segments means the UI gets base,
ours, and theirs as separate strings, per clash.
mergeModel.ts is the three-pane model in about eighty lines. Each clash carries a
ClashChoice of { kind, text, resolved }; Use mine and Use theirs fill
text, Keep both concatenates them (inserting a newline if ours lacks one),
and typing in the box is a custom choice worth exactly as much. reassemble()
walks the segments in order, copying stable text and splicing in each choice;
GitWorkspace.writeResolution writes the result and stages it.
Stable segments never get a control. They’re already merged — including edits both people made to different parts of the same file. Only real disagreements become questions.
The auto-merge rule, precisely
if (clash.ours === clash.base && clash.theirs !== clash.base) return theirs
if (clash.theirs === clash.base && clash.ours !== clash.base) return mine
return null
A clash region is a window, and windows run wider than the actual disagreement — two
nearby edits land in one chunk even when only one side moved. If one side is
byte-identical to the ancestor, that side edited nothing here: one edit is in the
window, and taking it loses nothing. When both sides moved, or neither did, the rule
refuses and returns null.
That refusal is the entire UX contract. Auto-merge easy parts resolves every untouched clash the rule can decide, never overrides a choice you already made, and jumps to the first one still open. What’s left is, by construction, two people writing different things in the same place — which no algorithm can adjudicate and a writer settles in seconds. “Merge what’s provable, ask about the rest” is what makes this usable by people who have never opened a terminal.
It is unit-tested in ThreeWayMergeTest and mergeModel.test.ts, and
e2e/tests/merge/merge.spec.js drives it for real: seed a Git conflict with the
git CLI, resolve each clash through the UI, save the merge, assert the file holds
the chosen text and no <<<<<<<. A second test asserts that cancelling restores the
local version.
What Markdown’s shape contributes
Most of the wins here aren’t in the merge code at all.
One page per file, one section per folder. Two people editing “the docs” are usually editing two different files, and different files merge without anyone being asked anything. The content layout is the concurrency-control mechanism, and it’s the one lever writers control.
Line-oriented text. MergeAlgorithm compares lines, and prose survives that
better than most formats — but not perfectly. The canonical formatter sets
RIGHT_MARGIN to 0 and keeps soft line breaks, so it never re-wraps your prose. That
is the right default (a reflow would turn a one-word edit into a whole-paragraph
diff), but a paragraph written as one long line is one diff line, and two people
editing different sentences of it will clash.
A canonical formatter on every write. Saves pass through
MarkdownService.canonicalize before the bytes reach disk, with fixed options:
ListBulletMarker.DASH, ListNumberedMarker.DOT, at most one blank line, no
trailing blank lines, and tables normalised — lead and trail pipes, adjusted column
widths, applied alignment, missing cells filled. Formatting isn’t a per-author
variable, so nobody produces a conflict by preferring * to - or padding a table
differently. The clashes that arrive are about meaning.
The limits, stated plainly
- Binary attachments don’t merge. The merge view reads both sides as UTF-8. A conflicting image lists like any other path with no meaningful side-by-side view. Pick the file you want and re-upload it.
- Cancel merge is a hard reset.
abortMerge()isgit reset --hard HEAD. It discards every uncommitted change in the checkout, not just the incoming ones, and Save a snapshot is disabled mid-merge, so you can’t rescue that work halfway through. The conflict guide carries this as a warning, and it means it. - Finishing a merge commits the working tree. The commit path stages everything tracked plus untracked content files, so unrelated saved edits ride along in the merge commit.
- No presence. Nothing watches the filesystem, nothing polls the remote. You learn about a collision at save time or at pull time, never earlier.
- No document locking. There is a
ReentrantLockinGitWorkspaceService, but it only serializes the editor’s own Git operations against each other. It is not a checkout system and it does not cross machines.
For the user-facing walkthrough — dialogs, panes, button names — see Resolve conflicts.
When you’d want a sync engine instead
If genuinely simultaneous editing is the normal case — six people typing meeting notes into one page as it happens — this model is the wrong one and you want a CRDT. Accept what comes with it: a service that must be up for editing to work, an operation log that is the real state, and files that are an export of that log.
If editing is mostly disjoint and review-shaped — one writer per page at a time, changes published in units — Git solved the merge problem decades ago, and the remaining work is presentation, not algorithms. That’s the bet here: a decent three-pane UI over a real three-way merge, auto-resolve everything provably safe, and the repository as the only thing anyone has to trust. The model-level tradeoffs are laid out in Git-backed documentation.