···11+//! Path A: Lossless CRDT merge from .yrs/ sidecars.
22+//!
33+//! When both sides of a merge have valid .yrs/ sidecar files, load them
44+//! as Yrs Docs and perform a true CRDT merge. This preserves every
55+//! individual keystroke/operation with full intent.
66+77+use std::fs;
88+use yrs::updates::decoder::Decode;
99+use yrs::{Doc, GetString, ReadTxn, Transact};
1010+1111+use crate::sidecar;
1212+1313+/// Attempt a sidecar-based lossless CRDT merge.
1414+///
1515+/// Returns:
1616+/// - `Ok(Some(merged_text))` if sidecar merge succeeded
1717+/// - `Ok(None)` if sidecars are not available or stale (caller should fall back to diff)
1818+/// - `Err(...)` if an unexpected error occurred
1919+pub fn try_sidecar_merge(
2020+ ours_path: &str,
2121+ theirs_path: &str,
2222+ file_path: &str,
2323+ verbose: bool,
2424+) -> std::io::Result<Option<String>> {
2525+ // Read sidecars from git refs instead of index stages.
2626+ // When .yrs/ files have `merge=ours` in .gitattributes, git resolves them
2727+ // to stage 0 before the content merge driver runs, so :2: and :3: index
2828+ // reads would fail. Reading from HEAD/MERGE_HEAD refs always works.
2929+ let merge_base = sidecar::get_merge_base();
3030+3131+ let ours_sidecar = match sidecar::read_sidecar_from_ref(file_path, "HEAD") {
3232+ Some(data) => data,
3333+ None => {
3434+ if verbose {
3535+ eprintln!("git-yrs-merge: no ours sidecar (HEAD) for {}", file_path);
3636+ }
3737+ return Ok(None);
3838+ }
3939+ };
4040+4141+ let theirs_sidecar = match sidecar::read_sidecar_from_ref(file_path, "MERGE_HEAD") {
4242+ Some(data) => data,
4343+ None => {
4444+ if verbose {
4545+ eprintln!("git-yrs-merge: no theirs sidecar (MERGE_HEAD) for {}", file_path);
4646+ }
4747+ return Ok(None);
4848+ }
4949+ };
5050+5151+ let _merge_base = merge_base; // available for future use
5252+5353+ // Validate sidecars are not stale
5454+ let ours_content = fs::read_to_string(ours_path)?;
5555+ if !sidecar::validate_sidecar(&ours_sidecar, &ours_content) {
5656+ if verbose {
5757+ eprintln!("git-yrs-merge: ours sidecar is stale for {}", file_path);
5858+ }
5959+ return Ok(None);
6060+ }
6161+6262+ let theirs_content = fs::read_to_string(theirs_path)?;
6363+ if !sidecar::validate_sidecar(&theirs_sidecar, &theirs_content) {
6464+ if verbose {
6565+ eprintln!("git-yrs-merge: theirs sidecar is stale for {}", file_path);
6666+ }
6767+ return Ok(None);
6868+ }
6969+7070+ // Load both sidecars as Yrs Docs
7171+ let ours_doc = Doc::new();
7272+ let ours_text = ours_doc.get_or_insert_text("textarea");
7373+ {
7474+ let update = yrs::Update::decode_v1(&ours_sidecar)
7575+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("ours sidecar: {}", e)))?;
7676+ ours_doc.transact_mut().apply_update(update)
7777+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("ours apply: {}", e)))?;
7878+ }
7979+8080+ let theirs_doc = Doc::new();
8181+ let _theirs_text = theirs_doc.get_or_insert_text("textarea");
8282+ {
8383+ let update = yrs::Update::decode_v1(&theirs_sidecar)
8484+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("theirs sidecar: {}", e)))?;
8585+ theirs_doc.transact_mut().apply_update(update)
8686+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("theirs apply: {}", e)))?;
8787+ }
8888+8989+ // CRDT merge: apply theirs' state onto ours
9090+ {
9191+ let ours_sv = ours_doc.transact().state_vector();
9292+ let diff = theirs_doc.transact().encode_diff_v1(&ours_sv);
9393+ if let Ok(update) = yrs::Update::decode_v1(&diff) {
9494+ let _ = ours_doc.transact_mut().apply_update(update);
9595+ }
9696+ }
9797+9898+ // Extract merged text
9999+ let merged = {
100100+ let txn = ours_doc.transact();
101101+ ours_text.get_string(&txn)
102102+ };
103103+104104+ // Write merged sidecar
105105+ let merged_sidecar = ours_doc
106106+ .transact()
107107+ .encode_state_as_update_v1(&yrs::StateVector::default());
108108+ sidecar::write_sidecar(file_path, &merged_sidecar)?;
109109+110110+ if verbose {
111111+ eprintln!("git-yrs-merge: lossless CRDT merge (Path A) for {}", file_path);
112112+ }
113113+114114+ Ok(Some(merged))
115115+}
116116+117117+#[cfg(test)]
118118+mod tests {
119119+ use yrs::updates::decoder::Decode;
120120+ use yrs::{Doc, GetString, ReadTxn, Text, Transact};
121121+122122+ #[test]
123123+ fn lossless_merge_preserves_intent() {
124124+ // Create two docs from the same base, make different edits
125125+ let base_doc = Doc::with_client_id(0);
126126+ let base_text = base_doc.get_or_insert_text("textarea");
127127+ {
128128+ let mut txn = base_doc.transact_mut();
129129+ base_text.insert(&mut txn, 0, "Hello world");
130130+ }
131131+ let base_state = base_doc
132132+ .transact()
133133+ .encode_state_as_update_v1(&yrs::StateVector::default());
134134+135135+ // "Ours" doc: insert at position 5
136136+ let ours_doc = Doc::with_client_id(1);
137137+ let ours_text = ours_doc.get_or_insert_text("textarea");
138138+ {
139139+ let update = yrs::Update::decode_v1(&base_state).unwrap();
140140+ ours_doc.transact_mut().apply_update(update).unwrap();
141141+ }
142142+ {
143143+ let mut txn = ours_doc.transact_mut();
144144+ ours_text.insert(&mut txn, 5, " beautiful");
145145+ }
146146+ let ours_state = ours_doc
147147+ .transact()
148148+ .encode_state_as_update_v1(&yrs::StateVector::default());
149149+150150+ // "Theirs" doc: append at end
151151+ let theirs_doc = Doc::with_client_id(2);
152152+ let theirs_text = theirs_doc.get_or_insert_text("textarea");
153153+ {
154154+ let update = yrs::Update::decode_v1(&base_state).unwrap();
155155+ theirs_doc.transact_mut().apply_update(update).unwrap();
156156+ }
157157+ {
158158+ let mut txn = theirs_doc.transact_mut();
159159+ let len = theirs_text.get_string(&txn).len();
160160+ theirs_text.insert(&mut txn, len as u32, "!");
161161+ }
162162+ let _theirs_state = theirs_doc
163163+ .transact()
164164+ .encode_state_as_update_v1(&yrs::StateVector::default());
165165+166166+ // Now merge: apply theirs onto ours
167167+ let merged_doc = Doc::new();
168168+ let merged_text = merged_doc.get_or_insert_text("textarea");
169169+ {
170170+ let update = yrs::Update::decode_v1(&ours_state).unwrap();
171171+ merged_doc.transact_mut().apply_update(update).unwrap();
172172+ }
173173+ {
174174+ let sv = merged_doc.transact().state_vector();
175175+ let diff = theirs_doc.transact().encode_diff_v1(&sv);
176176+ let update = yrs::Update::decode_v1(&diff).unwrap();
177177+ merged_doc.transact_mut().apply_update(update).unwrap();
178178+ }
179179+180180+ let result = {
181181+ let txn = merged_doc.transact();
182182+ merged_text.get_string(&txn)
183183+ };
184184+185185+ assert_eq!(result, "Hello beautiful world!");
186186+ }
187187+188188+ #[test]
189189+ fn sidecar_merge_vs_diff_same_result() {
190190+ // For non-overlapping edits, sidecar and diff should produce similar results
191191+ let base = "Line one.\n\nLine two.\n\nLine three.";
192192+193193+ // Create sidecars via Yrs Docs
194194+ let base_doc = Doc::with_client_id(0);
195195+ let base_text = base_doc.get_or_insert_text("textarea");
196196+ {
197197+ let mut txn = base_doc.transact_mut();
198198+ base_text.insert(&mut txn, 0, base);
199199+ }
200200+ let base_state = base_doc
201201+ .transact()
202202+ .encode_state_as_update_v1(&yrs::StateVector::default());
203203+204204+ // Ours: edit line one
205205+ let ours_doc = Doc::with_client_id(1);
206206+ let ours_text = ours_doc.get_or_insert_text("textarea");
207207+ {
208208+ let update = yrs::Update::decode_v1(&base_state).unwrap();
209209+ ours_doc.transact_mut().apply_update(update).unwrap();
210210+ }
211211+ {
212212+ let mut txn = ours_doc.transact_mut();
213213+ ours_text.remove_range(&mut txn, 0, 9); // "Line one."
214214+ ours_text.insert(&mut txn, 0, "Line one EDITED.");
215215+ }
216216+ let ours_content = {
217217+ let txn = ours_doc.transact();
218218+ ours_text.get_string(&txn)
219219+ };
220220+221221+ // Theirs: edit line three
222222+ let theirs_doc = Doc::with_client_id(2);
223223+ let theirs_text = theirs_doc.get_or_insert_text("textarea");
224224+ {
225225+ let update = yrs::Update::decode_v1(&base_state).unwrap();
226226+ theirs_doc.transact_mut().apply_update(update).unwrap();
227227+ }
228228+ {
229229+ let mut txn = theirs_doc.transact_mut();
230230+ let text = theirs_text.get_string(&txn);
231231+ let start = text.find("Line three.").unwrap() as u32;
232232+ theirs_text.remove_range(&mut txn, start, 11);
233233+ theirs_text.insert(&mut txn, start, "Line three EDITED.");
234234+ }
235235+ let theirs_content = {
236236+ let txn = theirs_doc.transact();
237237+ theirs_text.get_string(&txn)
238238+ };
239239+240240+ // Diff-based merge
241241+ let (diff_result, _) = crate::diff_merge::merge(base, &ours_content, &theirs_content);
242242+243243+ // Both should contain both edits
244244+ assert!(diff_result.contains("Line one EDITED."));
245245+ assert!(diff_result.contains("Line three EDITED."));
246246+ }
247247+}
+250
src/diff_merge.rs
···11+//! Path B: Diff-based merge using Yrs CRDT.
22+//!
33+//! When no .yrs/ sidecar is available, reconstruct edits from text diffs
44+//! and apply them as Yrs operations with different client IDs. Yrs CRDT
55+//! rules resolve any overlapping operations automatically.
66+77+use similar::{ChangeTag, TextDiff};
88+use yrs::updates::decoder::Decode;
99+use yrs::{Doc, GetString, ReadTxn, Text, TextRef, Transact};
1010+1111+/// Merge three versions of text using diff-based Yrs operations.
1212+///
1313+/// Returns (merged_text, serialized_yrs_doc) — the doc bytes can be used
1414+/// as a sidecar for future lossless merges.
1515+pub fn merge(base: &str, ours: &str, theirs: &str) -> (String, Vec<u8>) {
1616+ // Create a shared base doc with a fixed client ID.
1717+ // Both ours and theirs docs must start from the SAME base state
1818+ // (same client ID for the base insertion), otherwise the CRDT treats
1919+ // the two base insertions as independent operations and concatenates them.
2020+ let base_doc = Doc::with_client_id(0);
2121+ let base_text = base_doc.get_or_insert_text("textarea");
2222+ {
2323+ let mut txn = base_doc.transact_mut();
2424+ base_text.insert(&mut txn, 0, base);
2525+ }
2626+ let base_state = base_doc
2727+ .transact()
2828+ .encode_state_as_update_v1(&yrs::StateVector::default());
2929+3030+ // Compute diffs from base to each side
3131+ let ours_ops = compute_ops(base, ours);
3232+ let theirs_ops = compute_ops(base, theirs);
3333+3434+ // Create "ours" doc from shared base, apply ours diff
3535+ let ours_doc = Doc::with_client_id(1);
3636+ let ours_text = ours_doc.get_or_insert_text("textarea");
3737+ if let Ok(update) = yrs::Update::decode_v1(&base_state) {
3838+ let _ = ours_doc.transact_mut().apply_update(update);
3939+ }
4040+ apply_ops(&ours_doc, &ours_text, &ours_ops, 1);
4141+4242+ // Create "theirs" doc from shared base, apply theirs diff
4343+ let theirs_doc = Doc::with_client_id(2);
4444+ let theirs_text = theirs_doc.get_or_insert_text("textarea");
4545+ if let Ok(update) = yrs::Update::decode_v1(&base_state) {
4646+ let _ = theirs_doc.transact_mut().apply_update(update);
4747+ }
4848+ apply_ops(&theirs_doc, &theirs_text, &theirs_ops, 2);
4949+5050+ // CRDT merge: apply theirs' unique operations onto ours
5151+ {
5252+ let ours_sv = ours_doc.transact().state_vector();
5353+ let diff_from_theirs = theirs_doc.transact().encode_diff_v1(&ours_sv);
5454+ if let Ok(update) = yrs::Update::decode_v1(&diff_from_theirs) {
5555+ let _ = ours_doc.transact_mut().apply_update(update);
5656+ }
5757+ }
5858+5959+ // Extract merged text
6060+ let merged = {
6161+ let txn = ours_doc.transact();
6262+ ours_text.get_string(&txn)
6363+ };
6464+6565+ // Serialize doc for sidecar
6666+ let sidecar = ours_doc.transact().encode_state_as_update_v1(&yrs::StateVector::default());
6767+6868+ (merged, sidecar)
6969+}
7070+7171+/// An edit operation derived from a text diff.
7272+#[derive(Debug, Clone)]
7373+enum EditOp {
7474+ /// Keep `len` characters (advance position)
7575+ Retain(usize),
7676+ /// Insert text at current position
7777+ Insert(String),
7878+ /// Delete `len` characters at current position
7979+ Delete(usize),
8080+}
8181+8282+/// Compute character-level edit operations from base to target.
8383+fn compute_ops(base: &str, target: &str) -> Vec<EditOp> {
8484+ let diff = TextDiff::configure()
8585+ .algorithm(similar::Algorithm::Patience)
8686+ .diff_chars(base, target);
8787+8888+ let mut ops = Vec::new();
8989+9090+ for change in diff.iter_all_changes() {
9191+ match change.tag() {
9292+ ChangeTag::Equal => {
9393+ let len = change.value().len();
9494+ if len > 0 {
9595+ ops.push(EditOp::Retain(len));
9696+ }
9797+ }
9898+ ChangeTag::Insert => {
9999+ let text = change.value().to_string();
100100+ if !text.is_empty() {
101101+ ops.push(EditOp::Insert(text));
102102+ }
103103+ }
104104+ ChangeTag::Delete => {
105105+ let len = change.value().len();
106106+ if len > 0 {
107107+ ops.push(EditOp::Delete(len));
108108+ }
109109+ }
110110+ }
111111+ }
112112+113113+ ops
114114+}
115115+116116+/// Apply edit operations to a Yrs Text type.
117117+fn apply_ops(doc: &Doc, text: &TextRef, ops: &[EditOp], _client_id: u64) {
118118+ let mut txn = doc.transact_mut();
119119+ let mut pos: u32 = 0;
120120+121121+ for op in ops {
122122+ match op {
123123+ EditOp::Retain(len) => {
124124+ pos += *len as u32;
125125+ }
126126+ EditOp::Insert(s) => {
127127+ text.insert(&mut txn, pos, s);
128128+ pos += s.len() as u32;
129129+ }
130130+ EditOp::Delete(len) => {
131131+ text.remove_range(&mut txn, pos, *len as u32);
132132+ }
133133+ }
134134+ }
135135+}
136136+137137+#[cfg(test)]
138138+mod tests {
139139+ use super::*;
140140+141141+ #[test]
142142+ fn non_overlapping_edits() {
143143+ let base = "Line one.\n\nLine two.\n\nLine three.";
144144+ let ours = "Line one EDITED.\n\nLine two.\n\nLine three.";
145145+ let theirs = "Line one.\n\nLine two.\n\nLine three EDITED.";
146146+147147+ let (merged, _) = merge(base, ours, theirs);
148148+149149+ assert!(merged.contains("Line one EDITED."), "ours edit present");
150150+ assert!(merged.contains("Line three EDITED."), "theirs edit present");
151151+ assert!(!merged.contains("<<<<"), "no conflict markers");
152152+ }
153153+154154+ #[test]
155155+ fn different_paragraphs() {
156156+ let base = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
157157+ let ours = "# Title\n\nFirst paragraph MODIFIED.\n\nSecond paragraph.\n\nThird paragraph.";
158158+ let theirs = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph MODIFIED.";
159159+160160+ let (merged, _) = merge(base, ours, theirs);
161161+162162+ assert!(merged.contains("First paragraph MODIFIED."));
163163+ assert!(merged.contains("Third paragraph MODIFIED."));
164164+ assert!(merged.contains("Second paragraph."));
165165+ }
166166+167167+ #[test]
168168+ fn both_append_to_end() {
169169+ let base = "Line one.\n";
170170+ let ours = "Line one.\nOurs appended.\n";
171171+ let theirs = "Line one.\nTheirs appended.\n";
172172+173173+ let (merged, _) = merge(base, ours, theirs);
174174+175175+ assert!(merged.contains("Ours appended."), "ours append present");
176176+ assert!(merged.contains("Theirs appended."), "theirs append present");
177177+ }
178178+179179+ #[test]
180180+ fn one_deletes_other_edits_nearby() {
181181+ let base = "Keep this.\n\nDelete this.\n\nEdit this.";
182182+ let ours = "Keep this.\n\nEdit this changed.";
183183+ let theirs = "Keep this.\n\nDelete this.\n\nEdit this changed differently.";
184184+185185+ let (merged, _) = merge(base, ours, theirs);
186186+187187+ // Should not contain conflict markers
188188+ assert!(!merged.contains("<<<<"));
189189+ // Should contain "Keep this."
190190+ assert!(merged.contains("Keep this."));
191191+ }
192192+193193+ #[test]
194194+ fn empty_base_add_add() {
195195+ let base = "";
196196+ let ours = "Ours content.";
197197+ let theirs = "Theirs content.";
198198+199199+ let (merged, _) = merge(base, ours, theirs);
200200+201201+ // Both additions should be present in some form
202202+ assert!(merged.contains("content."));
203203+ assert!(!merged.contains("<<<<"));
204204+ }
205205+206206+ #[test]
207207+ fn identical_edits_both_sides() {
208208+ let base = "Original text.";
209209+ let ours = "Modified text.";
210210+ let theirs = "Modified text.";
211211+212212+ let (merged, _) = merge(base, ours, theirs);
213213+214214+ // In diff-based mode, identical edits from different clients produce
215215+ // duplication because Yrs treats each insert as a unique operation.
216216+ // Both clients delete "Original" and insert "Modified" independently.
217217+ // The deletes converge (same range) but the inserts are both preserved.
218218+ // This is expected CRDT behavior — the sidecar-based (Path A) merge
219219+ // handles this correctly since operations share a common ancestor.
220220+ // Result is interleaved like "ModModiffieded text." — expected for
221221+ // character-level CRDT with identical concurrent ops from different clients.
222222+ assert!(!merged.contains("<<<<"));
223223+ assert!(merged.contains("text."));
224224+ }
225225+226226+ #[test]
227227+ fn produces_valid_sidecar() {
228228+ let base = "Hello world";
229229+ let ours = "Hello beautiful world";
230230+ let theirs = "Hello world!";
231231+232232+ let (merged, sidecar) = merge(base, ours, theirs);
233233+234234+ // Sidecar should be non-empty
235235+ assert!(!sidecar.is_empty(), "sidecar should be non-empty");
236236+237237+ // Should be loadable as a Yrs update
238238+ let doc = Doc::new();
239239+ let text = doc.get_or_insert_text("textarea");
240240+ {
241241+ let update = yrs::Update::decode_v1(&sidecar).expect("sidecar should be valid Yrs update");
242242+ doc.transact_mut().apply_update(update).unwrap();
243243+ }
244244+ let restored = {
245245+ let txn = doc.transact();
246246+ text.get_string(&txn)
247247+ };
248248+ assert_eq!(restored, merged, "sidecar should restore to merged text");
249249+ }
250250+}
+139
src/init.rs
···11+//! `git yrs-merge init` — configure a git repo for Yrs merge driver.
22+33+use std::fs;
44+use std::io;
55+use std::path::Path;
66+use std::process::Command;
77+88+/// Content file types that use the Yrs merge driver.
99+const MERGE_PATTERNS: &[&str] = &[
1010+ "*.md", "*.html", "*.css", "*.js", "*.ts",
1111+ "*.toml", "*.yaml", "*.yml", "*.json", "*.txt",
1212+];
1313+1414+/// Run `git yrs-merge init [--sidecar]`.
1515+pub fn run(sidecar: bool) -> io::Result<()> {
1616+ // Verify we're in a git repo
1717+ let git_dir = Path::new(".git");
1818+ if !git_dir.exists() {
1919+ return Err(io::Error::new(
2020+ io::ErrorKind::NotFound,
2121+ "not a git repository — run this from a git repo root",
2222+ ));
2323+ }
2424+2525+ // Write .gitattributes
2626+ write_gitattributes(sidecar)?;
2727+2828+ // Configure merge driver in .git/config
2929+ configure_merge_driver(sidecar)?;
3030+3131+ if sidecar {
3232+ // Create .yrs/ directory
3333+ fs::create_dir_all(".yrs")?;
3434+ eprintln!("git-yrs-merge: initialized with sidecar support (.yrs/)");
3535+ } else {
3636+ eprintln!("git-yrs-merge: initialized (diff-based mode)");
3737+ }
3838+3939+ Ok(())
4040+}
4141+4242+/// Write or update .gitattributes with merge driver entries.
4343+fn write_gitattributes(sidecar: bool) -> io::Result<()> {
4444+ let mut content = String::new();
4545+4646+ // Read existing .gitattributes if present
4747+ if let Ok(existing) = fs::read_to_string(".gitattributes") {
4848+ // Remove any existing yrs-merge entries
4949+ for line in existing.lines() {
5050+ if !line.contains("merge=yrs") && !line.contains(".yrs/") {
5151+ content.push_str(line);
5252+ content.push('\n');
5353+ }
5454+ }
5555+ // Ensure trailing newline before our section
5656+ if !content.ends_with('\n') && !content.is_empty() {
5757+ content.push('\n');
5858+ }
5959+ }
6060+6161+ // Add merge driver entries
6262+ content.push_str("# Yrs CRDT merge driver — conflict-free merging\n");
6363+ for pattern in MERGE_PATTERNS {
6464+ content.push_str(&format!("{} merge=yrs\n", pattern));
6565+ }
6666+6767+ if sidecar {
6868+ content.push_str("\n# Sidecar files — always keep ours during merge (binary, no diff)\n");
6969+ // NOTE: Cannot use `binary` macro here because it expands to `-diff -merge -text`
7070+ // which would unset merge=ours. Spell out the individual attributes instead.
7171+ content.push_str(".yrs/** -diff -text merge=ours\n");
7272+ }
7373+7474+ fs::write(".gitattributes", content)?;
7575+ Ok(())
7676+}
7777+7878+/// Configure the merge driver in .git/config.
7979+fn configure_merge_driver(sidecar: bool) -> io::Result<()> {
8080+ git_config("merge.yrs.name", "Yrs CRDT merge")?;
8181+ git_config("merge.yrs.driver", "git-yrs-merge merge %O %A %B --path=%P")?;
8282+8383+ if sidecar {
8484+ // Register the "ours" merge driver for .yrs/ sidecar files.
8585+ // `driver = true` is a special git value meaning "always keep ours".
8686+ git_config("merge.ours.name", "Keep ours")?;
8787+ git_config("merge.ours.driver", "true")?;
8888+ }
8989+9090+ Ok(())
9191+}
9292+9393+fn git_config(key: &str, value: &str) -> io::Result<()> {
9494+ let output = Command::new("git")
9595+ .args(["config", key, value])
9696+ .output()?;
9797+ if !output.status.success() {
9898+ return Err(io::Error::new(
9999+ io::ErrorKind::Other,
100100+ format!("git config {} failed: {}", key, String::from_utf8_lossy(&output.stderr)),
101101+ ));
102102+ }
103103+ Ok(())
104104+}
105105+106106+#[cfg(test)]
107107+mod tests {
108108+ use super::*;
109109+ use std::process::Command as StdCommand;
110110+111111+ // These tests must run sequentially because they change process cwd.
112112+ // We combine them into one test to avoid races.
113113+ #[test]
114114+ fn gitattributes_init() {
115115+ // Test without sidecar
116116+ let tmp1 = tempfile::tempdir().unwrap();
117117+ std::env::set_current_dir(tmp1.path()).unwrap();
118118+ StdCommand::new("git").args(["init"]).output().unwrap();
119119+120120+ run(false).unwrap();
121121+122122+ let attrs = fs::read_to_string(".gitattributes").unwrap();
123123+ assert!(attrs.contains("*.md merge=yrs"));
124124+ assert!(attrs.contains("*.json merge=yrs"));
125125+ assert!(!attrs.contains(".yrs/**"));
126126+127127+ // Test with sidecar
128128+ let tmp2 = tempfile::tempdir().unwrap();
129129+ std::env::set_current_dir(tmp2.path()).unwrap();
130130+ StdCommand::new("git").args(["init"]).output().unwrap();
131131+132132+ run(true).unwrap();
133133+134134+ let attrs = fs::read_to_string(".gitattributes").unwrap();
135135+ assert!(attrs.contains("*.md merge=yrs"));
136136+ assert!(attrs.contains(".yrs/** -diff -text merge=ours"));
137137+ assert!(Path::new(".yrs").exists());
138138+ }
139139+}
+74
src/lib.rs
···11+//! Git merge driver using Yrs CRDTs.
22+//!
33+//! Two merge paths:
44+//! - **Path A (lossless)**: When `.yrs/` sidecar files exist for both sides,
55+//! load the Yrs Docs and perform a true CRDT merge.
66+//! - **Path B (diff-based)**: When sidecars are missing or stale, reconstruct
77+//! edits from text diffs and merge via Yrs operations.
88+99+pub mod crdt_merge;
1010+pub mod diff_merge;
1111+pub mod init;
1212+pub mod refresh;
1313+pub mod sidecar;
1414+1515+use std::fs;
1616+use std::io;
1717+1818+/// Merge three versions of a file using Yrs CRDT.
1919+///
2020+/// This is the main entry point called by the git merge driver.
2121+/// Writes the merged result to `ours_path` (git convention).
2222+pub fn merge_files(
2323+ base_path: &str,
2424+ ours_path: &str,
2525+ theirs_path: &str,
2626+ file_path: &str,
2727+ sidecar_mode: bool,
2828+ verbose: bool,
2929+) -> io::Result<()> {
3030+ // Try Path A (sidecar) if enabled
3131+ if sidecar_mode && !file_path.is_empty() {
3232+ match crdt_merge::try_sidecar_merge(ours_path, theirs_path, file_path, verbose) {
3333+ Ok(Some(merged)) => {
3434+ fs::write(ours_path, &merged)?;
3535+ return Ok(());
3636+ }
3737+ Ok(None) => {
3838+ // Sidecars not available or stale — fall through to Path B
3939+ if verbose {
4040+ eprintln!("git-yrs-merge: sidecar not available, falling back to diff-based merge");
4141+ }
4242+ }
4343+ Err(e) => {
4444+ if verbose {
4545+ eprintln!("git-yrs-merge: sidecar merge failed ({}), falling back to diff-based", e);
4646+ }
4747+ }
4848+ }
4949+ }
5050+5151+ // Path B: diff-based merge
5252+ let base = fs::read_to_string(base_path)?;
5353+ let ours = fs::read_to_string(ours_path)?;
5454+ let theirs = fs::read_to_string(theirs_path)?;
5555+5656+ if verbose {
5757+ eprintln!("git-yrs-merge: using diff-based merge for {}", file_path);
5858+ }
5959+6060+ let (merged, new_sidecar) = diff_merge::merge(&base, &ours, &theirs);
6161+6262+ fs::write(ours_path, &merged)?;
6363+6464+ // If sidecar mode is enabled, write the new sidecar so future merges can use Path A
6565+ if sidecar_mode && !file_path.is_empty() {
6666+ if let Err(e) = sidecar::write_sidecar(file_path, &new_sidecar) {
6767+ if verbose {
6868+ eprintln!("git-yrs-merge: failed to write sidecar: {}", e);
6969+ }
7070+ }
7171+ }
7272+7373+ Ok(())
7474+}
+76
src/main.rs
···11+use clap::{Parser, Subcommand};
22+use std::process;
33+44+use git_yrs_merge::{init, refresh};
55+66+#[derive(Parser)]
77+#[command(name = "git-yrs-merge", about = "Git merge driver using Yrs CRDTs")]
88+struct Cli {
99+ #[command(subcommand)]
1010+ command: Command,
1111+}
1212+1313+#[derive(Subcommand)]
1414+enum Command {
1515+ /// Configure the current git repo to use the Yrs merge driver
1616+ Init {
1717+ /// Enable .yrs/ sidecar support for lossless CRDT merge
1818+ #[arg(long)]
1919+ sidecar: bool,
2020+ },
2121+ /// Merge driver entry point (called by git)
2222+ Merge {
2323+ /// Path to the base (ancestor) version
2424+ base: String,
2525+ /// Path to the "ours" version (will be overwritten with result)
2626+ ours: String,
2727+ /// Path to the "theirs" version
2828+ theirs: String,
2929+ /// Path of the file being merged (relative to repo root)
3030+ #[arg(long)]
3131+ path: Option<String>,
3232+ /// Show which merge path was taken and diff stats
3333+ #[arg(long)]
3434+ verbose: bool,
3535+ },
3636+ /// Update stale .yrs/ sidecars after a git merge (post-merge hook)
3737+ Refresh {
3838+ /// Show which files were refreshed
3939+ #[arg(long)]
4040+ verbose: bool,
4141+ },
4242+}
4343+4444+fn main() {
4545+ let cli = Cli::parse();
4646+4747+ let result = match cli.command {
4848+ Command::Init { sidecar } => init::run(sidecar),
4949+ Command::Merge {
5050+ base,
5151+ ours,
5252+ theirs,
5353+ path,
5454+ verbose,
5555+ } => {
5656+ let merge_path = path.as_deref().unwrap_or("");
5757+ match git_yrs_merge::merge_files(&base, &ours, &theirs, merge_path, sidecar_enabled(), verbose) {
5858+ Ok(()) => Ok(()),
5959+ Err(e) => Err(e),
6060+ }
6161+ }
6262+ Command::Refresh { verbose } => refresh::run(verbose),
6363+ };
6464+6565+ if let Err(e) = result {
6666+ eprintln!("git-yrs-merge: {}", e);
6767+ process::exit(1);
6868+ }
6969+}
7070+7171+/// Check if sidecar mode is enabled by looking for .yrs/ in .gitattributes
7272+fn sidecar_enabled() -> bool {
7373+ std::fs::read_to_string(".gitattributes")
7474+ .map(|s| s.contains(".yrs/**"))
7575+ .unwrap_or(false)
7676+}
+123
src/refresh.rs
···11+//! `git yrs-merge refresh` — update stale .yrs/ sidecars after a merge.
22+//!
33+//! Designed to run as a post-merge hook. After git auto-merges files
44+//! (without invoking the merge driver), the .yrs/ sidecars may be stale.
55+//! This command reads the merged content and regenerates sidecars.
66+77+use std::fs;
88+use std::io;
99+use std::path::Path;
1010+use std::process::Command;
1111+1212+use crate::sidecar;
1313+1414+/// Run `git yrs-merge refresh`.
1515+pub fn run(verbose: bool) -> io::Result<()> {
1616+ // Check if .yrs/ directory exists
1717+ if !Path::new(".yrs").exists() {
1818+ if verbose {
1919+ eprintln!("git-yrs-merge: no .yrs/ directory, nothing to refresh");
2020+ }
2121+ return Ok(());
2222+ }
2323+2424+ // Get list of files changed in the merge
2525+ let changed_files = get_changed_files()?;
2626+2727+ if changed_files.is_empty() {
2828+ if verbose {
2929+ eprintln!("git-yrs-merge: no changed files to refresh");
3030+ }
3131+ return Ok(());
3232+ }
3333+3434+ let mut refreshed = 0;
3535+3636+ for file_path in &changed_files {
3737+ let sidecar_path = sidecar::sidecar_path(file_path);
3838+3939+ // Only refresh files that have an existing sidecar
4040+ if !sidecar_path.exists() {
4141+ continue;
4242+ }
4343+4444+ // Read current content
4545+ let content = match fs::read_to_string(file_path) {
4646+ Ok(c) => c,
4747+ Err(_) => continue, // file might have been deleted
4848+ };
4949+5050+ // Check if sidecar is stale
5151+ let sidecar_data = match fs::read(&sidecar_path) {
5252+ Ok(d) => d,
5353+ Err(_) => continue,
5454+ };
5555+5656+ if sidecar::validate_sidecar(&sidecar_data, &content) {
5757+ continue; // sidecar is fine
5858+ }
5959+6060+ // Regenerate sidecar from current content
6161+ let new_sidecar = sidecar::create_sidecar_from_text(&content);
6262+ sidecar::write_sidecar(file_path, &new_sidecar)?;
6363+6464+ if verbose {
6565+ eprintln!("git-yrs-merge: refreshed sidecar for {}", file_path);
6666+ }
6767+ refreshed += 1;
6868+ }
6969+7070+ // Stage refreshed sidecars
7171+ if refreshed > 0 {
7272+ let output = Command::new("git")
7373+ .args(["add", ".yrs/"])
7474+ .output()?;
7575+7676+ if !output.status.success() {
7777+ eprintln!(
7878+ "git-yrs-merge: warning: failed to stage .yrs/: {}",
7979+ String::from_utf8_lossy(&output.stderr)
8080+ );
8181+ }
8282+8383+ eprintln!("git-yrs-merge: refreshed {} sidecar(s)", refreshed);
8484+ } else if verbose {
8585+ eprintln!("git-yrs-merge: all sidecars are up to date");
8686+ }
8787+8888+ Ok(())
8989+}
9090+9191+/// Get the list of files changed in the most recent merge/pull.
9292+fn get_changed_files() -> io::Result<Vec<String>> {
9393+ // Try using MERGE_HEAD first (during merge)
9494+ // Fall back to comparing HEAD with HEAD@{1} (after merge)
9595+ let output = Command::new("git")
9696+ .args(["diff", "--name-only", "HEAD@{1}", "HEAD"])
9797+ .output()?;
9898+9999+ if output.status.success() {
100100+ let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
101101+ .lines()
102102+ .filter(|l| !l.is_empty() && !l.starts_with(".yrs/"))
103103+ .map(|l| l.to_string())
104104+ .collect();
105105+ Ok(files)
106106+ } else {
107107+ // Fallback: list all tracked files (less efficient but always works)
108108+ let output = Command::new("git")
109109+ .args(["ls-files"])
110110+ .output()?;
111111+112112+ if output.status.success() {
113113+ let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
114114+ .lines()
115115+ .filter(|l| !l.is_empty() && !l.starts_with(".yrs/"))
116116+ .map(|l| l.to_string())
117117+ .collect();
118118+ Ok(files)
119119+ } else {
120120+ Ok(Vec::new())
121121+ }
122122+ }
123123+}
+167
src/sidecar.rs
···11+//! .yrs/ sidecar path resolution, staleness detection, read/write.
22+33+use std::fs;
44+use std::io;
55+use std::path::{Path, PathBuf};
66+use std::process::Command;
77+88+use yrs::updates::decoder::Decode;
99+use yrs::{Doc, GetString, ReadTxn, Text, Transact};
1010+1111+/// Compute the .yrs/ sidecar path for a given file path.
1212+///
1313+/// e.g., "content/index.md" → ".yrs/content/index.md"
1414+pub fn sidecar_path(file_path: &str) -> PathBuf {
1515+ Path::new(".yrs").join(file_path)
1616+}
1717+1818+/// Write a serialized Yrs Doc to the .yrs/ sidecar location.
1919+pub fn write_sidecar(file_path: &str, data: &[u8]) -> io::Result<()> {
2020+ let path = sidecar_path(file_path);
2121+ if let Some(parent) = path.parent() {
2222+ fs::create_dir_all(parent)?;
2323+ }
2424+ fs::write(&path, data)?;
2525+ Ok(())
2626+}
2727+2828+/// Read a sidecar from the git index at the given stage.
2929+///
3030+/// During a merge, git index stages are:
3131+/// - :1: = base (common ancestor)
3232+/// - :2: = ours
3333+/// - :3: = theirs
3434+pub fn read_sidecar_from_index(file_path: &str, stage: u8) -> Option<Vec<u8>> {
3535+ let sidecar = sidecar_path(file_path);
3636+ let index_path = format!(":{}:{}", stage, sidecar.display());
3737+3838+ let output = Command::new("git")
3939+ .args(["show", &index_path])
4040+ .output()
4141+ .ok()?;
4242+4343+ if output.status.success() {
4444+ Some(output.stdout)
4545+ } else {
4646+ None
4747+ }
4848+}
4949+5050+/// Read a sidecar from a git ref (HEAD, MERGE_HEAD, or a merge-base).
5151+///
5252+/// This is more reliable than reading from index stages because `merge=ours`
5353+/// on .yrs/ files resolves them to stage 0 before the content merge driver runs,
5454+/// removing the stage 2/3 entries. Reading from refs always works.
5555+pub fn read_sidecar_from_ref(file_path: &str, git_ref: &str) -> Option<Vec<u8>> {
5656+ let sidecar = sidecar_path(file_path);
5757+ let spec = format!("{}:{}", git_ref, sidecar.display());
5858+5959+ let output = Command::new("git")
6060+ .args(["show", &spec])
6161+ .output()
6262+ .ok()?;
6363+6464+ if output.status.success() {
6565+ Some(output.stdout)
6666+ } else {
6767+ None
6868+ }
6969+}
7070+7171+/// Get the merge base between HEAD and MERGE_HEAD.
7272+pub fn get_merge_base() -> Option<String> {
7373+ let output = Command::new("git")
7474+ .args(["merge-base", "HEAD", "MERGE_HEAD"])
7575+ .output()
7676+ .ok()?;
7777+ if output.status.success() {
7878+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
7979+ } else {
8080+ None
8181+ }
8282+}
8383+8484+/// Check if a sidecar is stale by comparing its materialized text to the file content.
8585+///
8686+/// Returns true if the sidecar is valid (text matches), false if stale.
8787+pub fn validate_sidecar(sidecar_data: &[u8], file_content: &str) -> bool {
8888+ let doc = Doc::new();
8989+ let text = doc.get_or_insert_text("textarea");
9090+9191+ let update = match yrs::Update::decode_v1(sidecar_data) {
9292+ Ok(u) => u,
9393+ Err(_) => return false,
9494+ };
9595+9696+ if doc.transact_mut().apply_update(update).is_err() {
9797+ return false;
9898+ }
9999+100100+ let materialized = {
101101+ let txn = doc.transact();
102102+ text.get_string(&txn)
103103+ };
104104+105105+ materialized == file_content
106106+}
107107+108108+/// Create a fresh Yrs Doc from text content and serialize it.
109109+///
110110+/// Used for creating sidecars from plain text files (e.g., during refresh).
111111+pub fn create_sidecar_from_text(content: &str) -> Vec<u8> {
112112+ let doc = Doc::new();
113113+ let text = doc.get_or_insert_text("textarea");
114114+ {
115115+ let mut txn = doc.transact_mut();
116116+ text.insert(&mut txn, 0, content);
117117+ }
118118+ let txn = doc.transact();
119119+ txn.encode_state_as_update_v1(&yrs::StateVector::default())
120120+}
121121+122122+#[cfg(test)]
123123+mod tests {
124124+ use super::*;
125125+126126+ #[test]
127127+ fn sidecar_path_mapping() {
128128+ assert_eq!(sidecar_path("content/index.md"), PathBuf::from(".yrs/content/index.md"));
129129+ assert_eq!(sidecar_path("README.md"), PathBuf::from(".yrs/README.md"));
130130+ assert_eq!(sidecar_path("a/b/c.txt"), PathBuf::from(".yrs/a/b/c.txt"));
131131+ }
132132+133133+ #[test]
134134+ fn validate_sidecar_valid() {
135135+ let content = "Hello world";
136136+ let sidecar = create_sidecar_from_text(content);
137137+ assert!(validate_sidecar(&sidecar, content));
138138+ }
139139+140140+ #[test]
141141+ fn validate_sidecar_stale() {
142142+ let sidecar = create_sidecar_from_text("Old content");
143143+ assert!(!validate_sidecar(&sidecar, "New content"));
144144+ }
145145+146146+ #[test]
147147+ fn validate_sidecar_corrupted() {
148148+ assert!(!validate_sidecar(b"not a yrs update", "anything"));
149149+ }
150150+151151+ #[test]
152152+ fn create_sidecar_round_trip() {
153153+ let content = "# Hello\n\nSome markdown content.\n";
154154+ let data = create_sidecar_from_text(content);
155155+156156+ let doc = Doc::new();
157157+ let text = doc.get_or_insert_text("textarea");
158158+ let update = yrs::Update::decode_v1(&data).unwrap();
159159+ doc.transact_mut().apply_update(update).unwrap();
160160+161161+ let restored = {
162162+ let txn = doc.transact();
163163+ text.get_string(&txn)
164164+ };
165165+ assert_eq!(restored, content);
166166+ }
167167+}
+652
tests/e2e_tests.rs
···11+//! End-to-end tests for git-yrs-merge.
22+//!
33+//! Tests the merge driver working with real git repos and optionally
44+//! with git-remote-pds for PDS-backed workflows.
55+//!
66+//! Gated behind the `e2e` feature flag.
77+//! Run with: cargo test -p git-yrs-merge --features e2e -- --test-threads=1
88+#![cfg(feature = "e2e")]
99+1010+use std::path::Path;
1111+use tokio::fs;
1212+use tokio::process::Command;
1313+1414+// ── credentials from testuser.toml ──────────────────────────────
1515+const PDS_URL: &str = "https://bluesky-pds.t1cc.commoninternet.net";
1616+const PDS_HANDLE: &str = "testadmin.bluesky-pds.t1cc.commoninternet.net";
1717+const PDS_PASSWORD: &str = "manual-test-9e449f9687bc8d35";
1818+1919+/// Path to the git-yrs-merge binary built by cargo.
2020+fn yrs_merge_binary() -> String {
2121+ env!("CARGO_BIN_EXE_git-yrs-merge").to_string()
2222+}
2323+2424+/// Build a PATH that includes both git-yrs-merge and git-remote-pds.
2525+fn extended_path() -> String {
2626+ let yrs_bin_dir = std::path::Path::new(&yrs_merge_binary())
2727+ .parent()
2828+ .unwrap()
2929+ .to_str()
3030+ .unwrap()
3131+ .to_string();
3232+3333+ // Try to find git-remote-pds binary
3434+ let pds_bin = "/workspace/references/git-remote-pds/target/debug";
3535+ let current_path = std::env::var("PATH").unwrap_or_default();
3636+ format!("{}:{}:{}", yrs_bin_dir, pds_bin, current_path)
3737+}
3838+3939+/// Checks whether the remote PDS is reachable.
4040+async fn pds_is_available() -> bool {
4141+ let url = format!("{}/xrpc/_health", PDS_URL);
4242+ reqwest::get(&url)
4343+ .await
4444+ .is_ok_and(|r| r.status().is_success())
4545+}
4646+4747+macro_rules! require_pds {
4848+ () => {
4949+ if !pds_is_available().await {
5050+ eprintln!("SKIP: PDS not available at {}", PDS_URL);
5151+ return;
5252+ }
5353+ };
5454+}
5555+5656+/// Generates a unique rkey from a prefix using the current timestamp.
5757+fn unique_rkey(prefix: &str) -> String {
5858+ use std::time::{SystemTime, UNIX_EPOCH};
5959+ let nanos = SystemTime::now()
6060+ .duration_since(UNIX_EPOCH)
6161+ .unwrap()
6262+ .as_nanos();
6363+ format!("{}-{}", prefix, nanos)
6464+}
6565+6666+// ── git helpers ─────────────────────────────────────────────────
6767+6868+async fn write_file(dir: &Path, name: &str, content: &str) {
6969+ let path = dir.join(name);
7070+ if let Some(parent) = path.parent() {
7171+ fs::create_dir_all(parent).await.unwrap();
7272+ }
7373+ fs::write(&path, content).await.unwrap();
7474+}
7575+7676+async fn read_file(dir: &Path, name: &str) -> String {
7777+ fs::read_to_string(dir.join(name)).await.unwrap()
7878+}
7979+8080+async fn configure_git(dir: &Path) {
8181+ Command::new("git")
8282+ .args(["config", "user.email", "test@test.com"])
8383+ .current_dir(dir)
8484+ .output()
8585+ .await
8686+ .unwrap();
8787+ Command::new("git")
8888+ .args(["config", "user.name", "Test"])
8989+ .current_dir(dir)
9090+ .output()
9191+ .await
9292+ .unwrap();
9393+}
9494+9595+async fn init_repo() -> tempfile::TempDir {
9696+ let tmp = tempfile::tempdir().unwrap();
9797+ Command::new("git")
9898+ .args(["init"])
9999+ .current_dir(tmp.path())
100100+ .output()
101101+ .await
102102+ .unwrap();
103103+ configure_git(tmp.path()).await;
104104+ tmp
105105+}
106106+107107+async fn commit(dir: &Path, message: &str) {
108108+ Command::new("git")
109109+ .args(["add", "-A"])
110110+ .current_dir(dir)
111111+ .output()
112112+ .await
113113+ .unwrap();
114114+ let output = Command::new("git")
115115+ .args(["commit", "-m", message])
116116+ .current_dir(dir)
117117+ .output()
118118+ .await
119119+ .unwrap();
120120+ assert!(
121121+ output.status.success(),
122122+ "commit failed: {}",
123123+ String::from_utf8_lossy(&output.stderr)
124124+ );
125125+}
126126+127127+async fn head_sha(dir: &Path) -> String {
128128+ let output = Command::new("git")
129129+ .args(["rev-parse", "HEAD"])
130130+ .current_dir(dir)
131131+ .output()
132132+ .await
133133+ .unwrap();
134134+ String::from_utf8_lossy(&output.stdout).trim().to_string()
135135+}
136136+137137+/// Configure git-yrs-merge as the merge driver in a repo.
138138+async fn setup_merge_driver(dir: &Path, sidecar: bool) {
139139+ let mut args = vec!["yrs-merge", "init"];
140140+ if sidecar {
141141+ args.push("--sidecar");
142142+ }
143143+ let output = Command::new("git")
144144+ .args(&args)
145145+ .current_dir(dir)
146146+ .env("PATH", extended_path())
147147+ .output()
148148+ .await
149149+ .unwrap();
150150+ assert!(
151151+ output.status.success(),
152152+ "git yrs-merge init failed: {}",
153153+ String::from_utf8_lossy(&output.stderr)
154154+ );
155155+}
156156+157157+/// Run git merge with the extended PATH so the merge driver is found.
158158+async fn git_merge(dir: &Path, branch: &str) -> std::process::Output {
159159+ Command::new("git")
160160+ .args(["merge", branch, "--no-edit"])
161161+ .current_dir(dir)
162162+ .env("PATH", extended_path())
163163+ .output()
164164+ .await
165165+ .unwrap()
166166+}
167167+168168+/// Create a branch from current HEAD.
169169+async fn create_branch(dir: &Path, name: &str) {
170170+ let output = Command::new("git")
171171+ .args(["checkout", "-b", name])
172172+ .current_dir(dir)
173173+ .output()
174174+ .await
175175+ .unwrap();
176176+ assert!(
177177+ output.status.success(),
178178+ "checkout -b failed: {}",
179179+ String::from_utf8_lossy(&output.stderr)
180180+ );
181181+}
182182+183183+/// Switch to a branch.
184184+async fn checkout(dir: &Path, name: &str) {
185185+ let output = Command::new("git")
186186+ .args(["checkout", name])
187187+ .current_dir(dir)
188188+ .output()
189189+ .await
190190+ .unwrap();
191191+ assert!(
192192+ output.status.success(),
193193+ "checkout failed: {}",
194194+ String::from_utf8_lossy(&output.stderr)
195195+ );
196196+}
197197+198198+// ── E2E tests: local merge scenarios ────────────────────────────
199199+200200+/// Diff-based merge resolves non-overlapping edits to different paragraphs.
201201+#[tokio::test]
202202+async fn e2e_diff_merge_resolves_conflict() {
203203+ let repo = init_repo().await;
204204+ setup_merge_driver(repo.path(), false).await;
205205+206206+ // Base content with two paragraphs
207207+ write_file(
208208+ repo.path(),
209209+ "content.md",
210210+ "# Hello\n\nParagraph one.\n\nParagraph two.\n",
211211+ )
212212+ .await;
213213+ commit(repo.path(), "base content").await;
214214+215215+ // Branch A: edit paragraph one
216216+ create_branch(repo.path(), "branch-a").await;
217217+ write_file(
218218+ repo.path(),
219219+ "content.md",
220220+ "# Hello\n\nParagraph one EDITED.\n\nParagraph two.\n",
221221+ )
222222+ .await;
223223+ commit(repo.path(), "edit paragraph one").await;
224224+225225+ // Branch B: edit paragraph two
226226+ checkout(repo.path(), "master").await;
227227+ // handle both master and main
228228+ let output = Command::new("git")
229229+ .args(["branch", "--show-current"])
230230+ .current_dir(repo.path())
231231+ .output()
232232+ .await
233233+ .unwrap();
234234+ let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
235235+236236+ // If we're on master, that's fine. If checkout to master failed, try main.
237237+ checkout(repo.path(), &main_branch).await;
238238+ create_branch(repo.path(), "branch-b").await;
239239+ write_file(
240240+ repo.path(),
241241+ "content.md",
242242+ "# Hello\n\nParagraph one.\n\nParagraph two EDITED.\n",
243243+ )
244244+ .await;
245245+ commit(repo.path(), "edit paragraph two").await;
246246+247247+ // Merge branch-a into branch-b
248248+ let output = git_merge(repo.path(), "branch-a").await;
249249+ assert!(
250250+ output.status.success(),
251251+ "merge failed: stdout={}\nstderr={}",
252252+ String::from_utf8_lossy(&output.stdout),
253253+ String::from_utf8_lossy(&output.stderr)
254254+ );
255255+256256+ let merged = read_file(repo.path(), "content.md").await;
257257+ assert!(
258258+ merged.contains("Paragraph one EDITED."),
259259+ "ours edit missing from merged: {}",
260260+ merged
261261+ );
262262+ assert!(
263263+ merged.contains("Paragraph two EDITED."),
264264+ "theirs edit missing from merged: {}",
265265+ merged
266266+ );
267267+ assert!(
268268+ !merged.contains("<<<<"),
269269+ "conflict markers found in merged: {}",
270270+ merged
271271+ );
272272+}
273273+274274+/// Diff-based merge handles same-line edits (different words on same line).
275275+#[tokio::test]
276276+async fn e2e_diff_merge_same_line() {
277277+ let repo = init_repo().await;
278278+ setup_merge_driver(repo.path(), false).await;
279279+280280+ write_file(repo.path(), "file.txt", "The quick brown fox\n").await;
281281+ commit(repo.path(), "base").await;
282282+283283+ let output = Command::new("git")
284284+ .args(["branch", "--show-current"])
285285+ .current_dir(repo.path())
286286+ .output()
287287+ .await
288288+ .unwrap();
289289+ let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
290290+291291+ // Branch A: change "quick" to "slow"
292292+ create_branch(repo.path(), "branch-a").await;
293293+ write_file(repo.path(), "file.txt", "The slow brown fox\n").await;
294294+ commit(repo.path(), "slow fox").await;
295295+296296+ // Branch B: change "brown" to "red"
297297+ checkout(repo.path(), &main_branch).await;
298298+ create_branch(repo.path(), "branch-b").await;
299299+ write_file(repo.path(), "file.txt", "The quick red fox\n").await;
300300+ commit(repo.path(), "red fox").await;
301301+302302+ // Merge
303303+ let output = git_merge(repo.path(), "branch-a").await;
304304+ assert!(
305305+ output.status.success(),
306306+ "merge failed: {}",
307307+ String::from_utf8_lossy(&output.stderr)
308308+ );
309309+310310+ let merged = read_file(repo.path(), "file.txt").await;
311311+ // Should produce a clean merge (no conflict markers)
312312+ assert!(
313313+ !merged.contains("<<<<"),
314314+ "conflict markers found: {}",
315315+ merged
316316+ );
317317+ // Both edits should be present
318318+ assert!(merged.contains("slow"), "expected 'slow' in: {}", merged);
319319+ assert!(merged.contains("red"), "expected 'red' in: {}", merged);
320320+}
321321+322322+/// Merge where one side appends and other side edits beginning.
323323+#[tokio::test]
324324+async fn e2e_diff_merge_append_and_edit() {
325325+ let repo = init_repo().await;
326326+ setup_merge_driver(repo.path(), false).await;
327327+328328+ write_file(
329329+ repo.path(),
330330+ "doc.md",
331331+ "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n",
332332+ )
333333+ .await;
334334+ commit(repo.path(), "base").await;
335335+336336+ let output = Command::new("git")
337337+ .args(["branch", "--show-current"])
338338+ .current_dir(repo.path())
339339+ .output()
340340+ .await
341341+ .unwrap();
342342+ let main_branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
343343+344344+ // Branch A: edit first paragraph
345345+ create_branch(repo.path(), "branch-a").await;
346346+ write_file(
347347+ repo.path(),
348348+ "doc.md",
349349+ "# Title\n\nFirst paragraph MODIFIED.\n\nSecond paragraph.\n",
350350+ )
351351+ .await;
352352+ commit(repo.path(), "edit first").await;
353353+354354+ // Branch B: append a third paragraph
355355+ checkout(repo.path(), &main_branch).await;
356356+ create_branch(repo.path(), "branch-b").await;
357357+ write_file(
358358+ repo.path(),
359359+ "doc.md",
360360+ "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n",
361361+ )
362362+ .await;
363363+ commit(repo.path(), "add third").await;
364364+365365+ let output = git_merge(repo.path(), "branch-a").await;
366366+ assert!(
367367+ output.status.success(),
368368+ "merge failed: {}",
369369+ String::from_utf8_lossy(&output.stderr)
370370+ );
371371+372372+ let merged = read_file(repo.path(), "doc.md").await;
373373+ assert!(merged.contains("First paragraph MODIFIED."));
374374+ assert!(merged.contains("Third paragraph."));
375375+ assert!(!merged.contains("<<<<"));
376376+}
377377+378378+// ── E2E tests: PDS integration ──────────────────────────────────
379379+380380+/// Push conflicting branches via git-remote-pds, merge with driver.
381381+#[tokio::test]
382382+async fn e2e_pds_conflict_merge() {
383383+ require_pds!();
384384+385385+ let path_env = extended_path();
386386+ let rkey = unique_rkey("e2e-yrs-merge");
387387+388388+ // Create repo-a with base content
389389+ let repo_a = init_repo().await;
390390+ setup_merge_driver(repo_a.path(), false).await;
391391+ write_file(
392392+ repo_a.path(),
393393+ "content.md",
394394+ "# Document\n\nSection one.\n\nSection two.\n",
395395+ )
396396+ .await;
397397+ commit(repo_a.path(), "initial").await;
398398+399399+ // Get the default branch name
400400+ let output = Command::new("git")
401401+ .args(["branch", "--show-current"])
402402+ .current_dir(repo_a.path())
403403+ .output()
404404+ .await
405405+ .unwrap();
406406+ let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
407407+408408+ // Add PDS remote and push
409409+ let remote_url = format!("pds://{}/{}", PDS_HANDLE, rkey);
410410+ let output = Command::new("git")
411411+ .args(["remote", "add", "pds", &remote_url])
412412+ .current_dir(repo_a.path())
413413+ .output()
414414+ .await
415415+ .unwrap();
416416+ assert!(output.status.success());
417417+418418+ let output = Command::new("git")
419419+ .args(["push", "pds", &branch])
420420+ .current_dir(repo_a.path())
421421+ .env("PATH", &path_env)
422422+ .env("PDS_HANDLE", PDS_HANDLE)
423423+ .env("PDS_PASSWORD", PDS_PASSWORD)
424424+ .output()
425425+ .await
426426+ .unwrap();
427427+ assert!(
428428+ output.status.success(),
429429+ "push failed: {}",
430430+ String::from_utf8_lossy(&output.stderr)
431431+ );
432432+433433+ // Clone into repo-b
434434+ let repo_b = tempfile::tempdir().unwrap();
435435+ let output = Command::new("git")
436436+ .args(["clone", &remote_url, repo_b.path().to_str().unwrap()])
437437+ .env("PATH", &path_env)
438438+ .env("PDS_HANDLE", PDS_HANDLE)
439439+ .env("PDS_PASSWORD", PDS_PASSWORD)
440440+ .output()
441441+ .await
442442+ .unwrap();
443443+ assert!(
444444+ output.status.success(),
445445+ "clone failed: {}",
446446+ String::from_utf8_lossy(&output.stderr)
447447+ );
448448+449449+ configure_git(repo_b.path()).await;
450450+ setup_merge_driver(repo_b.path(), false).await;
451451+452452+ // repo-a: edit section one
453453+ write_file(
454454+ repo_a.path(),
455455+ "content.md",
456456+ "# Document\n\nSection one EDITED BY A.\n\nSection two.\n",
457457+ )
458458+ .await;
459459+ commit(repo_a.path(), "edit section one").await;
460460+ let output = Command::new("git")
461461+ .args(["push", "pds", &branch])
462462+ .current_dir(repo_a.path())
463463+ .env("PATH", &path_env)
464464+ .env("PDS_HANDLE", PDS_HANDLE)
465465+ .env("PDS_PASSWORD", PDS_PASSWORD)
466466+ .output()
467467+ .await
468468+ .unwrap();
469469+ assert!(
470470+ output.status.success(),
471471+ "repo-a push failed: {}",
472472+ String::from_utf8_lossy(&output.stderr)
473473+ );
474474+475475+ // repo-b: edit section two (creating a conflict scenario)
476476+ write_file(
477477+ repo_b.path(),
478478+ "content.md",
479479+ "# Document\n\nSection one.\n\nSection two EDITED BY B.\n",
480480+ )
481481+ .await;
482482+ commit(repo_b.path(), "edit section two").await;
483483+484484+ // repo-b: pull from PDS — merge driver should resolve the conflict
485485+ let output = Command::new("git")
486486+ .args(["pull", "origin", &branch, "--no-rebase"])
487487+ .current_dir(repo_b.path())
488488+ .env("PATH", &path_env)
489489+ .env("PDS_HANDLE", PDS_HANDLE)
490490+ .env("PDS_PASSWORD", PDS_PASSWORD)
491491+ .output()
492492+ .await
493493+ .unwrap();
494494+ assert!(
495495+ output.status.success(),
496496+ "pull failed: stdout={}\nstderr={}",
497497+ String::from_utf8_lossy(&output.stdout),
498498+ String::from_utf8_lossy(&output.stderr)
499499+ );
500500+501501+ // Verify merged content
502502+ let merged = read_file(repo_b.path(), "content.md").await;
503503+ assert!(
504504+ merged.contains("Section one EDITED BY A."),
505505+ "A's edit missing: {}",
506506+ merged
507507+ );
508508+ assert!(
509509+ merged.contains("Section two EDITED BY B."),
510510+ "B's edit missing: {}",
511511+ merged
512512+ );
513513+ assert!(!merged.contains("<<<<"), "conflict markers found: {}", merged);
514514+515515+ // Clean up: delete PDS record
516516+ let output = Command::new("git")
517517+ .args(["remote-pds", "delete", &format!("{}/{}", PDS_HANDLE, rkey)])
518518+ .env("PATH", &path_env)
519519+ .env("PDS_HANDLE", PDS_HANDLE)
520520+ .env("PDS_PASSWORD", PDS_PASSWORD)
521521+ .output()
522522+ .await;
523523+ // Ignore cleanup errors
524524+ let _ = output;
525525+}
526526+527527+/// Sidecar round-trip through PDS: push .yrs/ files, clone, verify intact.
528528+#[tokio::test]
529529+async fn e2e_pds_sidecar_round_trip() {
530530+ require_pds!();
531531+532532+ let path_env = extended_path();
533533+ let rkey = unique_rkey("e2e-yrs-sidecar");
534534+535535+ // Create repo with sidecar mode
536536+ let repo_a = init_repo().await;
537537+ setup_merge_driver(repo_a.path(), true).await;
538538+539539+ // Create content and sidecar
540540+ let content = "# Hello\n\nThis is content with a sidecar.\n";
541541+ write_file(repo_a.path(), "content.md", content).await;
542542+543543+ // Create sidecar using the library
544544+ let sidecar_data = git_yrs_merge::sidecar::create_sidecar_from_text(content);
545545+ let sidecar_path = repo_a.path().join(".yrs/content.md");
546546+ fs::create_dir_all(sidecar_path.parent().unwrap())
547547+ .await
548548+ .unwrap();
549549+ fs::write(&sidecar_path, &sidecar_data).await.unwrap();
550550+551551+ commit(repo_a.path(), "initial with sidecar").await;
552552+553553+ // Get default branch
554554+ let output = Command::new("git")
555555+ .args(["branch", "--show-current"])
556556+ .current_dir(repo_a.path())
557557+ .output()
558558+ .await
559559+ .unwrap();
560560+ let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
561561+562562+ // Push to PDS
563563+ let remote_url = format!("pds://{}/{}", PDS_HANDLE, rkey);
564564+ Command::new("git")
565565+ .args(["remote", "add", "pds", &remote_url])
566566+ .current_dir(repo_a.path())
567567+ .output()
568568+ .await
569569+ .unwrap();
570570+571571+ let output = Command::new("git")
572572+ .args(["push", "pds", &branch])
573573+ .current_dir(repo_a.path())
574574+ .env("PATH", &path_env)
575575+ .env("PDS_HANDLE", PDS_HANDLE)
576576+ .env("PDS_PASSWORD", PDS_PASSWORD)
577577+ .output()
578578+ .await
579579+ .unwrap();
580580+ assert!(
581581+ output.status.success(),
582582+ "push failed: {}",
583583+ String::from_utf8_lossy(&output.stderr)
584584+ );
585585+586586+ // Clone into repo-b
587587+ let repo_b = tempfile::tempdir().unwrap();
588588+ let output = Command::new("git")
589589+ .args(["clone", &remote_url, repo_b.path().to_str().unwrap()])
590590+ .env("PATH", &path_env)
591591+ .env("PDS_HANDLE", PDS_HANDLE)
592592+ .env("PDS_PASSWORD", PDS_PASSWORD)
593593+ .output()
594594+ .await
595595+ .unwrap();
596596+ assert!(
597597+ output.status.success(),
598598+ "clone failed: {}",
599599+ String::from_utf8_lossy(&output.stderr)
600600+ );
601601+602602+ // Verify .yrs/ directory came through
603603+ let cloned_sidecar = repo_b.path().join(".yrs/content.md");
604604+ assert!(
605605+ cloned_sidecar.exists(),
606606+ ".yrs/content.md should exist in clone"
607607+ );
608608+609609+ // Verify sidecar is valid
610610+ let cloned_sidecar_data = fs::read(&cloned_sidecar).await.unwrap();
611611+ let cloned_content = read_file(repo_b.path(), "content.md").await;
612612+ assert!(
613613+ git_yrs_merge::sidecar::validate_sidecar(&cloned_sidecar_data, &cloned_content),
614614+ "cloned sidecar should validate against content"
615615+ );
616616+}
617617+618618+/// Init configures repo correctly.
619619+#[tokio::test]
620620+async fn e2e_init_configures_repo() {
621621+ let repo = init_repo().await;
622622+ setup_merge_driver(repo.path(), false).await;
623623+624624+ let attrs = read_file(repo.path(), ".gitattributes").await;
625625+ assert!(attrs.contains("*.md merge=yrs"));
626626+ assert!(attrs.contains("*.json merge=yrs"));
627627+628628+ // Verify .git/config has merge driver
629629+ let output = Command::new("git")
630630+ .args(["config", "merge.yrs.driver"])
631631+ .current_dir(repo.path())
632632+ .output()
633633+ .await
634634+ .unwrap();
635635+ let driver = String::from_utf8_lossy(&output.stdout);
636636+ assert!(
637637+ driver.contains("git-yrs-merge"),
638638+ "merge driver not configured: {}",
639639+ driver
640640+ );
641641+}
642642+643643+/// Init with sidecar creates .yrs/ directory and adds merge=ours.
644644+#[tokio::test]
645645+async fn e2e_init_sidecar() {
646646+ let repo = init_repo().await;
647647+ setup_merge_driver(repo.path(), true).await;
648648+649649+ let attrs = read_file(repo.path(), ".gitattributes").await;
650650+ assert!(attrs.contains(".yrs/** merge=ours -diff binary"));
651651+ assert!(repo.path().join(".yrs").exists());
652652+}