← All posts

Two writers, one Markdown file: collaboration without a sync engine

gitengineeringcollaboration

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 baselineHashLocalEditService.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:

StagesKindWhat it means for a document
1, 2, 3BOTH_MODIFIEDYou both edited overlapping regions of the page
1, 3 (no 2)DELETE_MODIFYYou deleted the page; they edited it
1, 2 (no 3)MODIFY_DELETEThey deleted the page; you edited it
no baseADD_ADDYou 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() is git 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 ReentrantLock in GitWorkspaceService, 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.

See it for yourself

Docs as Markdown in your Git.

No database, no lock-in. Browse the live demo with no signup, or start a 14-day trial on your own infrastructure — no credit card required.