--- title: A 3-Way Text Merger That Never Shows Conflict Markers description: reconcile-text merges Markdown notes from three editors I don't control, with no history. Why git, CRDTs, and diff-match-patch each failed me. date: 2026-05-21 period: '2025' thumbnail: src: ./_assets/reconcile.png alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging". links: - label: Demo url: /reconcile/ - label: Source url: https://git.schmelczer.dev/andras/reconcile - label: crates.io url: https://crates.io/crates/reconcile-text - label: npm url: https://www.npmjs.com/package/reconcile-text - label: PyPI url: https://pypi.org/project/reconcile-text/ article: featuredOrder: 2 tags: ['systems', 'tools', 'web'] stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen'] scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin outcome: A small Rust library that auto-resolves prose conflicts, with WASM and Python bindings media: - type: image src: ./_assets/reconcile.png alt: The reconcile-text logo, a stylised merge arrow, with the tagline "Conflict-free 3-way text merging". caption: reconcile-text weaves conflicting edits together instead of asking a human to choose. project: title: reconcile-text selected: true technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff'] --- ## Why I wrote it I keep Markdown notes in three editors I don't control the internals of: Vim on my laptop, VS Code on my work machine, Obsidian on my phone. When two of them edit the same note between syncs, I have three files: the last-synced parent and two divergent children. That's the input. I want one merged file out, and I want to hand it back to the editors without conflict markers, because `<<<<<<< HEAD` is not something a notes app should ever show me. Every existing tool got close and missed: - `git merge-file` does exactly the right thing structurally, then writes markers into the output. That's correct for source code and wrong for prose. - CRDTs and OT both assume you own the editing pipeline down to the keystroke. I don't. I'm looking at three files. - `diff-match-patch` doesn't take a common ancestor. On adjacent edits it quietly produces wrong output. I have a runnable example in the repo. So the library does exactly one thing: pure function from three strings to one. No async, no networking, no concurrency, no plugins. Anything outside that boundary is somebody else's library. ## The decisions that mattered **Myers diff per side, then weave the diffs.** Each child is diffed against the parent, the two edit scripts are optimised so adjacent changes group cleanly, then a single weaving pass interleaves them into one ordered op sequence that produces the merged text. The weave borrows the shape of operational transformation, but the inputs are batched complete diffs, not live keystrokes, so it only runs once per merge. **Tokeniser is the user knob.** Of everything in the library, this is the choice I'd argue for longest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token. **Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge, so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. The test fixtures mark cursor positions with a `|` straight in the YAML, which keeps these cases readable enough that I actually write them. Cursor support is the bit that made the library useful to anything that wasn't just [the Obsidian sync plugin I wrote alongside it](/articles/vault-link-obsidian-sync/). **The Rust core is generic; the FFI surface is not.** Inside Rust, the tokeniser is a `dyn Fn(&str) -> Vec>`. That dies the moment you try to pass it through wasm-bindgen or pyo3. The fix was a closed enum of built-in tokenisers for non-Rust callers, with the generic version reserved for Rust users. Not elegant, but the alternative was per-binding glue forever. **WASM size mattered enough to tune for it.** The release profile is aggressive about size, and the JS package ships a small leak detector that warns if you forget to free wasm-bindgen objects. I lost an afternoon to that the first time and didn't want anyone else to. The strangest target is React Native: Hermes doesn't run WASM, so the package transpiles the WASM back into JavaScript with Binaryen: slower, but the same Rust core gets to follow the library anywhere JavaScript runs. ## What's held up, what I'd change - **Kept:** the never-emits-markers, never-drops-edits guarantee. It's the only reason a sync engine can call this library without an escape hatch. - **Kept:** the comparison example against `diff-match-patch`. It's a runnable program in the repo showing exact inputs where the alternative is wrong. Way more convincing than a benchmark table. - **Regret:** the snapshot and fixture tests do well on regressions and badly on edge cases nobody has imagined yet. Three-way merging is exactly the shape of problem property-based testing was made for, and the generators still aren't written. Saying it here is partly a way of making myself do it. - **Next:** I want to be more explicit about the boundary. reconcile-text is a merge primitive, not a live collab engine. If you have a keystroke stream and a real-time channel, use Yjs or Automerge. This library is for when you don't. ## If you take one idea from this Prose deserves a merger that prefers a slightly clumsy sentence over a conflict marker. Code doesn't. That one asymmetry is the whole reason the library exists in the shape it does; every other decision is downstream of taking it seriously.