1use anyhow::{Context, Result};
57use std::process::Command;
58
59pub fn merge_contents_crdt(
66 base_state: Option<&[u8]>,
67 ours: &str,
68 theirs: &str,
69) -> Result<(String, Vec<u8>)> {
70 let merged = crate::crdt::merge(base_state, ours, theirs)
71 .context("CRDT merge failed")?;
72 let doc = crate::crdt::CrdtDoc::from_text(&merged);
74 let state = doc.encode_state();
75 eprintln!("[write] CRDT merge successful — no conflicts possible.");
76 Ok((merged, state))
77}
78
79pub fn merge_contents(base: &str, ours: &str, theirs: &str) -> Result<String> {
86 let tmp = tempfile::TempDir::new()
87 .context("failed to create temp dir for merge")?;
88
89 let base_path = tmp.path().join("base");
90 let ours_path = tmp.path().join("ours");
91 let theirs_path = tmp.path().join("theirs");
92
93 std::fs::write(&base_path, base)?;
94 std::fs::write(&ours_path, ours)?;
95 std::fs::write(&theirs_path, theirs)?;
96
97 let output = Command::new("git")
98 .current_dir(tmp.path())
99 .args([
100 "merge-file",
101 "-p",
102 "--diff3",
103 "-L", "agent-response",
104 "-L", "original",
105 "-L", "your-edits",
106 &ours_path.to_string_lossy(),
107 &base_path.to_string_lossy(),
108 &theirs_path.to_string_lossy(),
109 ])
110 .output()?;
111
112 let merged = String::from_utf8(output.stdout)
113 .map_err(|e| anyhow::anyhow!("merge produced invalid UTF-8: {}", e))?;
114
115 if output.status.success() {
116 eprintln!("[write] Merge successful — user edits preserved.");
117 return Ok(merged);
118 }
119
120 if output.status.code() == Some(1) {
121 let (resolved, remaining_conflicts) = resolve_append_conflicts(&merged);
123 if remaining_conflicts {
124 eprintln!("[write] WARNING: True merge conflicts remain. Please resolve conflict markers manually.");
125 } else {
126 eprintln!("[write] Merge conflicts auto-resolved (append-friendly).");
127 }
128 return Ok(resolved);
129 }
130
131 anyhow::bail!(
132 "git merge-file failed: {}",
133 String::from_utf8_lossy(&output.stderr)
134 )
135}
136
137fn resolve_append_conflicts(merged: &str) -> (String, bool) {
156 let mut result = String::new();
157 let mut has_remaining = false;
158 let lines: Vec<&str> = merged.lines().collect();
159 let len = lines.len();
160 let mut i = 0;
161
162 while i < len {
163 if !lines[i].starts_with("<<<<<<< ") {
164 result.push_str(lines[i]);
165 result.push('\n');
166 i += 1;
167 continue;
168 }
169
170 let conflict_start = i;
172 i += 1; let mut ours_lines: Vec<&str> = Vec::new();
176 while i < len && !lines[i].starts_with("||||||| ") && !lines[i].starts_with("=======") {
177 ours_lines.push(lines[i]);
178 i += 1;
179 }
180
181 let mut original_lines: Vec<&str> = Vec::new();
183 if i < len && lines[i].starts_with("||||||| ") {
184 i += 1; while i < len && !lines[i].starts_with("=======") {
186 original_lines.push(lines[i]);
187 i += 1;
188 }
189 }
190
191 if i < len && lines[i].starts_with("=======") {
193 i += 1;
194 }
195
196 let mut theirs_lines: Vec<&str> = Vec::new();
198 while i < len && !lines[i].starts_with(">>>>>>> ") {
199 theirs_lines.push(lines[i]);
200 i += 1;
201 }
202
203 if i < len && lines[i].starts_with(">>>>>>> ") {
205 i += 1;
206 }
207
208 let is_append_only = original_lines.iter().all(|l| l.trim().is_empty());
210
211 if is_append_only {
212 for line in &ours_lines {
214 result.push_str(line);
215 result.push('\n');
216 }
217 for line in &theirs_lines {
218 result.push_str(line);
219 result.push('\n');
220 }
221 } else {
222 has_remaining = true;
224 result.push_str(lines[conflict_start]);
225 result.push('\n');
226 for line in &ours_lines {
227 result.push_str(line);
228 result.push('\n');
229 }
230 if !original_lines.is_empty() {
232 result.push_str("||||||| original\n");
233 for line in &original_lines {
234 result.push_str(line);
235 result.push('\n');
236 }
237 }
238 result.push_str("=======\n");
239 for line in &theirs_lines {
240 result.push_str(line);
241 result.push('\n');
242 }
243 result.push_str(">>>>>>> your-edits\n");
244 }
245 }
246
247 if !merged.ends_with('\n') && result.ends_with('\n') {
249 result.pop();
250 }
251
252 (result, has_remaining)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn resolve_append_only_conflict() {
261 let merged = "\
262Before conflict
263<<<<<<< agent-response
264Agent added this line.
265||||||| original
266=======
267User added this line.
268>>>>>>> your-edits
269After conflict
270";
271 let (resolved, has_remaining) = resolve_append_conflicts(merged);
272 assert!(!has_remaining);
273 assert!(resolved.contains("Agent added this line."));
274 assert!(resolved.contains("User added this line."));
275 assert!(!resolved.contains("<<<<<<<"));
276 assert!(!resolved.contains(">>>>>>>"));
277 let agent_pos = resolved.find("Agent added this line.").unwrap();
279 let user_pos = resolved.find("User added this line.").unwrap();
280 assert!(agent_pos < user_pos);
281 }
282
283 #[test]
284 fn preserve_true_conflict() {
285 let merged = "\
286<<<<<<< agent-response
287Agent changed this.
288||||||| original
289Original line that both sides modified.
290=======
291User changed this differently.
292>>>>>>> your-edits
293";
294 let (resolved, has_remaining) = resolve_append_conflicts(merged);
295 assert!(has_remaining);
296 assert!(resolved.contains("<<<<<<<"));
297 assert!(resolved.contains(">>>>>>>"));
298 assert!(resolved.contains("Original line that both sides modified."));
299 }
300
301 #[test]
302 fn mixed_append_and_true_conflicts() {
303 let merged = "\
304Clean line.
305<<<<<<< agent-response
306Agent appended here.
307||||||| original
308=======
309User appended here.
310>>>>>>> your-edits
311Middle line.
312<<<<<<< agent-response
313Agent rewrote this.
314||||||| original
315Was originally this.
316=======
317User rewrote this differently.
318>>>>>>> your-edits
319End line.
320";
321 let (resolved, has_remaining) = resolve_append_conflicts(merged);
322 assert!(has_remaining);
323 assert!(resolved.contains("Agent appended here."));
325 assert!(resolved.contains("User appended here."));
326 assert!(resolved.contains("<<<<<<<"));
328 assert!(resolved.contains("Was originally this."));
329 }
330
331 #[test]
332 fn no_conflicts_passthrough() {
333 let merged = "Line one.\nLine two.\nLine three.\n";
334 let (resolved, has_remaining) = resolve_append_conflicts(merged);
335 assert!(!has_remaining);
336 assert_eq!(resolved, merged);
337 }
338
339 #[test]
340 fn multiline_append_conflict() {
341 let merged = "\
342<<<<<<< agent-response
343Agent line 1.
344Agent line 2.
345Agent line 3.
346||||||| original
347=======
348User line 1.
349User line 2.
350>>>>>>> your-edits
351";
352 let (resolved, has_remaining) = resolve_append_conflicts(merged);
353 assert!(!has_remaining);
354 assert!(resolved.contains("Agent line 1.\nAgent line 2.\nAgent line 3.\n"));
355 assert!(resolved.contains("User line 1.\nUser line 2.\n"));
356 assert!(resolved.find("Agent line 1.").unwrap() < resolved.find("User line 1.").unwrap());
358 }
359
360 #[test]
361 fn merge_contents_clean() {
362 let base = "Line 1\nLine 2\n";
363 let ours = "Line 1\nLine 2\nAgent added\n";
364 let theirs = "Line 1\nLine 2\n";
365 let result = merge_contents(base, ours, theirs).unwrap();
366 assert!(result.contains("Agent added"));
367 }
368
369 #[test]
370 fn crdt_merge_agent_and_user_append() {
371 let base = "# Doc\n\nBase content.\n";
372 let ours = "# Doc\n\nBase content.\n\nAgent response.\n";
373 let theirs = "# Doc\n\nBase content.\n\nUser addition.\n";
374
375 let base_doc = crate::crdt::CrdtDoc::from_text(base);
376 let base_state = base_doc.encode_state();
377
378 let (merged, _state) = merge_contents_crdt(Some(&base_state), ours, theirs).unwrap();
379 assert!(merged.contains("Agent response."));
380 assert!(merged.contains("User addition."));
381 assert!(merged.contains("Base content."));
382 assert!(!merged.contains("<<<<<<<"));
383 }
384
385 #[test]
386 fn crdt_merge_concurrent_same_line() {
387 let base = "Line 1\nLine 3\n";
388 let ours = "Line 1\nAgent\nLine 3\n";
389 let theirs = "Line 1\nUser\nLine 3\n";
390
391 let base_doc = crate::crdt::CrdtDoc::from_text(base);
392 let base_state = base_doc.encode_state();
393
394 let (merged, _state) = merge_contents_crdt(Some(&base_state), ours, theirs).unwrap();
395 assert!(merged.contains("Agent"));
397 assert!(merged.contains("User"));
398 assert!(merged.contains("Line 1"));
399 assert!(merged.contains("Line 3"));
400 }
401
402 #[test]
403 fn crdt_merge_no_base_state_bootstrap() {
404 let ours = "Agent content.\n";
405 let theirs = "User content.\n";
406
407 let (merged, state) = merge_contents_crdt(None, ours, theirs).unwrap();
408 assert!(merged.contains("Agent content."));
409 assert!(merged.contains("User content."));
410 assert!(!state.is_empty());
411 }
412
413 #[test]
414 fn crdt_merge_one_side_unchanged() {
415 let base = "Original.\n";
416 let base_doc = crate::crdt::CrdtDoc::from_text(base);
417 let base_state = base_doc.encode_state();
418
419 let ours = "Original.\nAgent added.\n";
420 let (merged, _) = merge_contents_crdt(Some(&base_state), ours, base).unwrap();
421 assert_eq!(merged, ours);
422 }
423
424 #[test]
425 fn merge_contents_both_append() {
426 let base = "Line 1\n";
427 let ours = "Line 1\nAgent response\n";
428 let theirs = "Line 1\nUser edit\n";
429 let result = merge_contents(base, ours, theirs).unwrap();
430 assert!(result.contains("Agent response"));
432 assert!(result.contains("User edit"));
433 assert!(!result.contains("<<<<<<<"));
434 }
435
436 #[test]
449 fn crdt_state_includes_user_edits_no_duplicates() {
450 let initial = "Why were the videos not public?\n";
452 let initial_doc = crate::crdt::CrdtDoc::from_text(initial);
453 let initial_state = initial_doc.encode_state();
454
455 let ours_cycle1 = "Why were the videos not public?\nAlways publish public videos.\n";
457 let theirs_cycle1 = "Why were the videos not public?\nuser-edit-abc\n";
459
460 let (merged1, state1) = merge_contents_crdt(
461 Some(&initial_state), ours_cycle1, theirs_cycle1
462 ).unwrap();
463
464 assert!(merged1.contains("Always publish public videos."), "missing agent response");
466 assert!(merged1.contains("user-edit-abc"), "missing user edit");
467
468 let ours_cycle2 = format!("{}...unless explicitly set to private.\n", merged1);
471 let theirs_cycle2 = merged1.clone();
473
474 let (merged2, _state2) = merge_contents_crdt(
475 Some(&state1), &ours_cycle2, &theirs_cycle2
476 ).unwrap();
477
478 let edit_count = merged2.matches("user-edit-abc").count();
480 assert_eq!(
481 edit_count, 1,
482 "User edit duplicated! Appeared {} times in:\n{}",
483 edit_count, merged2
484 );
485
486 assert!(merged2.contains("Always publish public videos."));
488 assert!(merged2.contains("...unless explicitly set to private."));
489 }
490
491 #[test]
496 fn crdt_multi_flush_no_duplicates() {
497 let base = "# Doc\n\nQuestion here.\n";
498 let base_doc = crate::crdt::CrdtDoc::from_text(base);
499 let state0 = base_doc.encode_state();
500
501 let ours1 = "# Doc\n\nQuestion here.\n\n### Re: Answer\n\nFirst paragraph.\n";
503 let theirs1 = "# Doc\n\nQuestion here.\n\n> user note\n";
504 let (merged1, state1) = merge_contents_crdt(Some(&state0), ours1, theirs1).unwrap();
505 assert!(merged1.contains("First paragraph."));
506 assert!(merged1.contains("> user note"));
507
508 let ours2 = format!("{}\nSecond paragraph.\n", merged1);
510 let theirs2 = format!("{}\n> another note\n", merged1);
511 let (merged2, _state2) = merge_contents_crdt(Some(&state1), &ours2, &theirs2).unwrap();
512
513 assert_eq!(merged2.matches("First paragraph.").count(), 1,
515 "First paragraph duplicated in:\n{}", merged2);
516 assert_eq!(merged2.matches("> user note").count(), 1,
517 "User note duplicated in:\n{}", merged2);
518 assert!(merged2.contains("Second paragraph."));
519 assert!(merged2.contains("> another note"));
520 }
521}