1use crate::schema::{edge_col, node_col};
13use arrow::array::{Array, RecordBatch, StringArray};
14use std::collections::HashMap;
15
16#[derive(Debug, thiserror::Error)]
18pub enum GitToolError {
19 #[error("Arrow error: {0}")]
20 Arrow(#[from] arrow::error::ArrowError),
21
22 #[error("No nodes batch provided")]
23 NoBatch,
24}
25
26pub type Result<T> = std::result::Result<T, GitToolError>;
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct CodeDiffEntry {
31 pub node_id: String,
33 pub kind: String,
35 pub name: String,
37 pub change_type: CodeDiffChangeType,
39 pub old_hash: Option<String>,
41 pub new_hash: Option<String>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum CodeDiffChangeType {
48 Added,
49 Removed,
50 Modified,
51}
52
53impl std::fmt::Display for CodeDiffChangeType {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 CodeDiffChangeType::Added => f.write_str("added"),
57 CodeDiffChangeType::Removed => f.write_str("removed"),
58 CodeDiffChangeType::Modified => f.write_str("modified"),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct CodeDiffResult {
66 pub entries: Vec<CodeDiffEntry>,
67}
68
69impl CodeDiffResult {
70 pub fn added(&self) -> Vec<&CodeDiffEntry> {
71 self.entries
72 .iter()
73 .filter(|e| e.change_type == CodeDiffChangeType::Added)
74 .collect()
75 }
76
77 pub fn removed(&self) -> Vec<&CodeDiffEntry> {
78 self.entries
79 .iter()
80 .filter(|e| e.change_type == CodeDiffChangeType::Removed)
81 .collect()
82 }
83
84 pub fn modified(&self) -> Vec<&CodeDiffEntry> {
85 self.entries
86 .iter()
87 .filter(|e| e.change_type == CodeDiffChangeType::Modified)
88 .collect()
89 }
90
91 pub fn is_empty(&self) -> bool {
92 self.entries.is_empty()
93 }
94
95 pub fn summary(&self) -> String {
96 format!(
97 "{} added, {} modified, {} removed",
98 self.added().len(),
99 self.modified().len(),
100 self.removed().len()
101 )
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct CodeConflict {
108 pub node_id: String,
110 pub ours_hash: Option<String>,
112 pub theirs_hash: Option<String>,
114 pub base_hash: Option<String>,
116}
117
118#[derive(Debug)]
120pub struct CodeMergeResult {
121 pub merged_batch: Option<RecordBatch>,
123 pub conflicts: Vec<CodeConflict>,
125 pub clean_merges: usize,
127}
128
129impl CodeMergeResult {
130 pub fn has_conflicts(&self) -> bool {
131 !self.conflicts.is_empty()
132 }
133}
134
135fn extract_node_hashes(batch: &RecordBatch) -> HashMap<String, NodeSnapshot> {
137 let mut map = HashMap::new();
138
139 if batch.num_rows() == 0 {
140 return map;
141 }
142
143 let ids = batch
144 .column(node_col::ID)
145 .as_any()
146 .downcast_ref::<StringArray>()
147 .expect("id column");
148 let body_hashes = batch
149 .column(node_col::BODY_HASH)
150 .as_any()
151 .downcast_ref::<StringArray>()
152 .expect("body_hash column");
153 let names = batch
154 .column(node_col::NAME)
155 .as_any()
156 .downcast_ref::<StringArray>()
157 .expect("name column");
158
159 let kind_col = batch.column(node_col::KIND);
161 let kind_dict = kind_col
162 .as_any()
163 .downcast_ref::<arrow::array::Int8DictionaryArray>()
164 .expect("kind dict");
165 let kind_values = kind_dict
166 .values()
167 .as_any()
168 .downcast_ref::<StringArray>()
169 .expect("kind values");
170
171 for i in 0..batch.num_rows() {
172 let id = ids.value(i).to_string();
173 let hash = if body_hashes.is_null(i) {
174 None
175 } else {
176 Some(body_hashes.value(i).to_string())
177 };
178 let name = names.value(i).to_string();
179 let kind_key = kind_dict.keys().value(i) as usize;
180 let kind = kind_values.value(kind_key).to_string();
181
182 map.insert(id, NodeSnapshot { hash, name, kind });
183 }
184
185 map
186}
187
188#[derive(Debug, Clone)]
189struct NodeSnapshot {
190 hash: Option<String>,
191 name: String,
192 kind: String,
193}
194
195pub fn codegraph_diff(
201 base_batch: &RecordBatch,
202 head_batch: &RecordBatch,
203) -> Result<CodeDiffResult> {
204 let base_map = extract_node_hashes(base_batch);
205 let head_map = extract_node_hashes(head_batch);
206
207 let mut entries = Vec::new();
208
209 for (id, head_snap) in &head_map {
211 match base_map.get(id) {
212 None => {
213 entries.push(CodeDiffEntry {
214 node_id: id.clone(),
215 kind: head_snap.kind.clone(),
216 name: head_snap.name.clone(),
217 change_type: CodeDiffChangeType::Added,
218 old_hash: None,
219 new_hash: head_snap.hash.clone(),
220 });
221 }
222 Some(base_snap) => {
223 if base_snap.hash != head_snap.hash {
224 entries.push(CodeDiffEntry {
225 node_id: id.clone(),
226 kind: head_snap.kind.clone(),
227 name: head_snap.name.clone(),
228 change_type: CodeDiffChangeType::Modified,
229 old_hash: base_snap.hash.clone(),
230 new_hash: head_snap.hash.clone(),
231 });
232 }
233 }
234 }
235 }
236
237 for (id, base_snap) in &base_map {
239 if !head_map.contains_key(id) {
240 entries.push(CodeDiffEntry {
241 node_id: id.clone(),
242 kind: base_snap.kind.clone(),
243 name: base_snap.name.clone(),
244 change_type: CodeDiffChangeType::Removed,
245 old_hash: base_snap.hash.clone(),
246 new_hash: None,
247 });
248 }
249 }
250
251 entries.sort_by(|a, b| a.node_id.cmp(&b.node_id));
253
254 Ok(CodeDiffResult { entries })
255}
256
257pub fn codegraph_merge(
263 base_batch: &RecordBatch,
264 ours_batch: &RecordBatch,
265 theirs_batch: &RecordBatch,
266) -> Result<CodeMergeResult> {
267 let base_map = extract_node_hashes(base_batch);
268 let ours_map = extract_node_hashes(ours_batch);
269 let theirs_map = extract_node_hashes(theirs_batch);
270
271 let mut conflicts = Vec::new();
272 let mut clean_merges = 0usize;
273
274 let mut all_ids: Vec<String> = ours_map
276 .keys()
277 .chain(theirs_map.keys())
278 .cloned()
279 .collect::<std::collections::HashSet<_>>()
280 .into_iter()
281 .collect();
282 all_ids.sort();
283
284 for id in &all_ids {
285 let base_hash = base_map.get(id).and_then(|s| s.hash.as_deref());
286 let ours_hash = ours_map.get(id).and_then(|s| s.hash.as_deref());
287 let theirs_hash = theirs_map.get(id).and_then(|s| s.hash.as_deref());
288
289 let ours_changed = ours_hash != base_hash;
291 let theirs_changed = theirs_hash != base_hash;
292
293 if ours_changed && theirs_changed && ours_hash != theirs_hash {
294 conflicts.push(CodeConflict {
295 node_id: id.clone(),
296 ours_hash: ours_hash.map(|s| s.to_string()),
297 theirs_hash: theirs_hash.map(|s| s.to_string()),
298 base_hash: base_hash.map(|s| s.to_string()),
299 });
300 } else {
301 clean_merges += 1;
302 }
303 }
304
305 let merged_batch = if conflicts.is_empty() {
312 Some(ours_batch.clone())
313 } else {
314 None };
316
317 Ok(CodeMergeResult {
318 merged_batch,
319 conflicts,
320 clean_merges,
321 })
322}
323
324#[derive(Debug, Clone)]
328pub struct MergeWarning {
329 pub shared_dep: String,
331 pub ours_callers: Vec<String>,
333 pub theirs_callers: Vec<String>,
335}
336
337#[derive(Debug)]
339pub struct SmartMergeResult {
340 pub merge: CodeMergeResult,
342 pub warnings: Vec<MergeWarning>,
344}
345
346impl SmartMergeResult {
347 pub fn has_conflicts(&self) -> bool {
348 self.merge.has_conflicts()
349 }
350
351 pub fn has_warnings(&self) -> bool {
352 !self.warnings.is_empty()
353 }
354}
355
356pub fn smart_merge(
362 base_batch: &RecordBatch,
363 ours_batch: &RecordBatch,
364 theirs_batch: &RecordBatch,
365 edges_batch: &RecordBatch,
366) -> Result<SmartMergeResult> {
367 let merge = codegraph_merge(base_batch, ours_batch, theirs_batch)?;
368
369 let base_map = extract_node_hashes(base_batch);
371 let ours_map = extract_node_hashes(ours_batch);
372 let theirs_map = extract_node_hashes(theirs_batch);
373
374 let ours_changed: std::collections::HashSet<String> = ours_map
375 .iter()
376 .filter(|(id, snap)| {
377 base_map
378 .get(id.as_str())
379 .map(|b| b.hash != snap.hash)
380 .unwrap_or(true) })
382 .map(|(id, _)| id.clone())
383 .collect();
384
385 let theirs_changed: std::collections::HashSet<String> = theirs_map
386 .iter()
387 .filter(|(id, snap)| {
388 base_map
389 .get(id.as_str())
390 .map(|b| b.hash != snap.hash)
391 .unwrap_or(true)
392 })
393 .map(|(id, _)| id.clone())
394 .collect();
395
396 let warnings = detect_interaction_warnings(&ours_changed, &theirs_changed, edges_batch);
398
399 Ok(SmartMergeResult { merge, warnings })
400}
401
402fn detect_interaction_warnings(
404 ours_changed: &std::collections::HashSet<String>,
405 theirs_changed: &std::collections::HashSet<String>,
406 edges_batch: &RecordBatch,
407) -> Vec<MergeWarning> {
408 use crate::schema::CodeEdgePredicate;
409
410 if edges_batch.num_rows() == 0 || ours_changed.is_empty() || theirs_changed.is_empty() {
411 return Vec::new();
412 }
413
414 let sources = edges_batch
415 .column(edge_col::SOURCE_ID)
416 .as_any()
417 .downcast_ref::<StringArray>()
418 .expect("source_id");
419 let targets = edges_batch
420 .column(edge_col::TARGET_ID)
421 .as_any()
422 .downcast_ref::<StringArray>()
423 .expect("target_id");
424 let pred_dict = edges_batch
425 .column(edge_col::PREDICATE)
426 .as_any()
427 .downcast_ref::<arrow::array::Int8DictionaryArray>()
428 .expect("predicate dict");
429 let pred_values = pred_dict
430 .values()
431 .as_any()
432 .downcast_ref::<StringArray>()
433 .expect("pred values");
434 let weights = edges_batch
435 .column(edge_col::WEIGHT)
436 .as_any()
437 .downcast_ref::<arrow::array::Float32Array>()
438 .expect("weight");
439
440 let calls_str = CodeEdgePredicate::Calls.as_str();
441
442 let mut ours_calls: HashMap<String, Vec<String>> = HashMap::new();
444 let mut theirs_calls: HashMap<String, Vec<String>> = HashMap::new();
445
446 for i in 0..edges_batch.num_rows() {
447 if !weights.is_null(i) && weights.value(i) < 0.0 {
448 continue;
449 }
450 let pred_key = pred_dict.keys().value(i) as usize;
451 if pred_values.value(pred_key) != calls_str {
452 continue;
453 }
454
455 let source = sources.value(i);
456 let target = targets.value(i).to_string();
457
458 if ours_changed.contains(source) {
459 ours_calls
460 .entry(target.clone())
461 .or_default()
462 .push(source.to_string());
463 }
464 if theirs_changed.contains(source) {
465 theirs_calls
466 .entry(target)
467 .or_default()
468 .push(source.to_string());
469 }
470 }
471
472 let mut warnings = Vec::new();
474 for (dep, ours_callers) in &ours_calls {
475 if let Some(theirs_callers) = theirs_calls.get(dep) {
476 warnings.push(MergeWarning {
477 shared_dep: dep.clone(),
478 ours_callers: ours_callers.clone(),
479 theirs_callers: theirs_callers.clone(),
480 });
481 }
482 }
483 warnings.sort_by(|a, b| a.shared_dep.cmp(&b.shared_dep));
484
485 warnings
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::schema::{CodeNode, CodeNodeKind, build_code_nodes_batch};
492
493 fn base_nodes() -> Vec<CodeNode> {
494 vec![
495 CodeNode {
496 id: "func:a.py::foo".into(),
497 kind: CodeNodeKind::Function,
498 parent_id: Some("mod:a.py".into()),
499 name: "foo".into(),
500 signature: Some("def foo()".into()),
501 docstring: None,
502 body_hash: Some("hash_v1".into()),
503 body: None,
504 loc: Some(10),
505 cyclomatic_complexity: Some(2),
506 coverage_pct: None,
507 last_modified: None,
508 ..Default::default()
509 },
510 CodeNode {
511 id: "func:a.py::bar".into(),
512 kind: CodeNodeKind::Function,
513 parent_id: Some("mod:a.py".into()),
514 name: "bar".into(),
515 signature: Some("def bar()".into()),
516 docstring: None,
517 body_hash: Some("hash_bar".into()),
518 body: None,
519 loc: Some(20),
520 cyclomatic_complexity: Some(5),
521 coverage_pct: None,
522 last_modified: None,
523 ..Default::default()
524 },
525 ]
526 }
527
528 #[test]
531 fn test_diff_no_changes() {
532 let batch = build_code_nodes_batch(&base_nodes()).unwrap();
533 let result = codegraph_diff(&batch, &batch).unwrap();
534 assert!(result.is_empty());
535 }
536
537 #[test]
538 fn test_diff_added_node() {
539 let base = build_code_nodes_batch(&base_nodes()).unwrap();
540 let mut head_nodes = base_nodes();
541 head_nodes.push(CodeNode {
542 id: "func:b.py::baz".into(),
543 kind: CodeNodeKind::Function,
544 parent_id: None,
545 name: "baz".into(),
546 signature: None,
547 docstring: None,
548 body_hash: Some("hash_new".into()),
549 body: None,
550 loc: None,
551 cyclomatic_complexity: None,
552 coverage_pct: None,
553 last_modified: None,
554 ..Default::default()
555 });
556 let head = build_code_nodes_batch(&head_nodes).unwrap();
557
558 let result = codegraph_diff(&base, &head).unwrap();
559 assert_eq!(result.added().len(), 1);
560 assert_eq!(result.added()[0].name, "baz");
561 assert_eq!(result.modified().len(), 0);
562 assert_eq!(result.removed().len(), 0);
563 }
564
565 #[test]
566 fn test_diff_removed_node() {
567 let base = build_code_nodes_batch(&base_nodes()).unwrap();
568 let head_nodes = vec![base_nodes()[0].clone()]; let head = build_code_nodes_batch(&head_nodes).unwrap();
570
571 let result = codegraph_diff(&base, &head).unwrap();
572 assert_eq!(result.removed().len(), 1);
573 assert_eq!(result.removed()[0].name, "bar");
574 }
575
576 #[test]
577 fn test_diff_modified_node() {
578 let base = build_code_nodes_batch(&base_nodes()).unwrap();
579 let mut head_nodes = base_nodes();
580 head_nodes[0].body_hash = Some("hash_v2".into()); let head = build_code_nodes_batch(&head_nodes).unwrap();
582
583 let result = codegraph_diff(&base, &head).unwrap();
584 assert_eq!(result.modified().len(), 1);
585 assert_eq!(result.modified()[0].name, "foo");
586 assert_eq!(result.modified()[0].old_hash, Some("hash_v1".into()));
587 assert_eq!(result.modified()[0].new_hash, Some("hash_v2".into()));
588 }
589
590 #[test]
591 fn test_diff_summary() {
592 let base = build_code_nodes_batch(&base_nodes()).unwrap();
593 let mut head_nodes = base_nodes();
594 head_nodes[0].body_hash = Some("hash_v2".into());
595 head_nodes.push(CodeNode {
596 id: "func:c.py::new_func".into(),
597 kind: CodeNodeKind::Function,
598 parent_id: None,
599 name: "new_func".into(),
600 signature: None,
601 docstring: None,
602 body_hash: Some("new_hash".into()),
603 body: None,
604 loc: None,
605 cyclomatic_complexity: None,
606 coverage_pct: None,
607 last_modified: None,
608 ..Default::default()
609 });
610 let head = build_code_nodes_batch(&head_nodes).unwrap();
611
612 let result = codegraph_diff(&base, &head).unwrap();
613 assert_eq!(result.summary(), "1 added, 1 modified, 0 removed");
614 }
615
616 #[test]
617 fn test_diff_empty_batches() {
618 let empty = build_code_nodes_batch(&[]).unwrap();
619 let result = codegraph_diff(&empty, &empty).unwrap();
620 assert!(result.is_empty());
621 }
622
623 #[test]
626 fn test_merge_no_changes() {
627 let base = build_code_nodes_batch(&base_nodes()).unwrap();
628 let result = codegraph_merge(&base, &base, &base).unwrap();
629 assert!(!result.has_conflicts());
630 assert!(result.merged_batch.is_some());
631 }
632
633 #[test]
634 fn test_merge_one_side_changes() {
635 let base = build_code_nodes_batch(&base_nodes()).unwrap();
636 let mut ours_nodes = base_nodes();
637 ours_nodes[0].body_hash = Some("ours_hash".into());
638 let ours = build_code_nodes_batch(&ours_nodes).unwrap();
639
640 let result = codegraph_merge(&base, &ours, &base).unwrap();
641 assert!(!result.has_conflicts());
642 assert!(result.merged_batch.is_some());
643 }
644
645 #[test]
646 fn test_merge_both_sides_same_change() {
647 let base = build_code_nodes_batch(&base_nodes()).unwrap();
648 let mut changed = base_nodes();
649 changed[0].body_hash = Some("same_hash".into());
650 let ours = build_code_nodes_batch(&changed).unwrap();
651 let theirs = build_code_nodes_batch(&changed).unwrap();
652
653 let result = codegraph_merge(&base, &ours, &theirs).unwrap();
654 assert!(!result.has_conflicts()); }
656
657 #[test]
658 fn test_merge_conflict() {
659 let base = build_code_nodes_batch(&base_nodes()).unwrap();
660 let mut ours_nodes = base_nodes();
661 ours_nodes[0].body_hash = Some("ours_hash".into());
662 let ours = build_code_nodes_batch(&ours_nodes).unwrap();
663
664 let mut theirs_nodes = base_nodes();
665 theirs_nodes[0].body_hash = Some("theirs_hash".into());
666 let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
667
668 let result = codegraph_merge(&base, &ours, &theirs).unwrap();
669 assert!(result.has_conflicts());
670 assert_eq!(result.conflicts.len(), 1);
671 assert_eq!(result.conflicts[0].node_id, "func:a.py::foo");
672 assert_eq!(result.conflicts[0].ours_hash, Some("ours_hash".into()));
673 assert_eq!(result.conflicts[0].theirs_hash, Some("theirs_hash".into()));
674 assert_eq!(result.conflicts[0].base_hash, Some("hash_v1".into()));
675 assert!(result.merged_batch.is_none()); }
677
678 #[test]
679 fn test_merge_conflict_reports_to_agent() {
680 let base = build_code_nodes_batch(&base_nodes()).unwrap();
681 let mut ours_nodes = base_nodes();
682 ours_nodes[0].body_hash = Some("v_ours".into());
683 ours_nodes[1].body_hash = Some("v_ours_bar".into());
684 let ours = build_code_nodes_batch(&ours_nodes).unwrap();
685
686 let mut theirs_nodes = base_nodes();
687 theirs_nodes[0].body_hash = Some("v_theirs".into());
688 let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
690
691 let result = codegraph_merge(&base, &ours, &theirs).unwrap();
692 assert_eq!(result.conflicts.len(), 1); assert_eq!(result.conflicts[0].node_id, "func:a.py::foo");
694 }
696
697 use crate::schema::{CodeEdge, CodeEdgePredicate, build_code_edges_batch};
700
701 fn smart_merge_nodes() -> Vec<CodeNode> {
702 vec![
703 CodeNode {
704 id: "func:a.py::caller_a".into(),
705 kind: CodeNodeKind::Function,
706 parent_id: None,
707 name: "caller_a".into(),
708 signature: None,
709 docstring: None,
710 body_hash: Some("ca_v1".into()),
711 body: None,
712 loc: Some(10),
713 cyclomatic_complexity: None,
714 coverage_pct: None,
715 last_modified: None,
716 ..Default::default()
717 },
718 CodeNode {
719 id: "func:a.py::caller_b".into(),
720 kind: CodeNodeKind::Function,
721 parent_id: None,
722 name: "caller_b".into(),
723 signature: None,
724 docstring: None,
725 body_hash: Some("cb_v1".into()),
726 body: None,
727 loc: Some(10),
728 cyclomatic_complexity: None,
729 coverage_pct: None,
730 last_modified: None,
731 ..Default::default()
732 },
733 CodeNode {
734 id: "func:b.py::shared_dep".into(),
735 kind: CodeNodeKind::Function,
736 parent_id: None,
737 name: "shared_dep".into(),
738 signature: None,
739 docstring: None,
740 body_hash: Some("sd_v1".into()),
741 body: None,
742 loc: Some(20),
743 cyclomatic_complexity: None,
744 coverage_pct: None,
745 last_modified: None,
746 ..Default::default()
747 },
748 ]
749 }
750
751 fn smart_merge_edges() -> Vec<CodeEdge> {
752 vec![
753 CodeEdge {
755 source_id: "func:a.py::caller_a".into(),
756 target_id: "func:b.py::shared_dep".into(),
757 predicate: CodeEdgePredicate::Calls,
758 weight: Some(1.0),
759 commit_id: None,
760 },
761 CodeEdge {
763 source_id: "func:a.py::caller_b".into(),
764 target_id: "func:b.py::shared_dep".into(),
765 predicate: CodeEdgePredicate::Calls,
766 weight: Some(1.0),
767 commit_id: None,
768 },
769 ]
770 }
771
772 #[test]
773 fn test_smart_merge_no_interaction() {
774 let base = build_code_nodes_batch(&smart_merge_nodes()).unwrap();
775 let edges = build_code_edges_batch(&smart_merge_edges()).unwrap();
776
777 let mut ours_nodes = smart_merge_nodes();
779 ours_nodes[0].body_hash = Some("ca_v2".into());
780 let ours = build_code_nodes_batch(&ours_nodes).unwrap();
781
782 let result = smart_merge(&base, &ours, &base, &edges).unwrap();
783 assert!(!result.has_conflicts());
784 assert!(!result.has_warnings());
785 }
786
787 #[test]
788 fn test_smart_merge_interaction_warning() {
789 let base = build_code_nodes_batch(&smart_merge_nodes()).unwrap();
790 let edges = build_code_edges_batch(&smart_merge_edges()).unwrap();
791
792 let mut ours_nodes = smart_merge_nodes();
795 ours_nodes[0].body_hash = Some("ca_v2".into());
796 let ours = build_code_nodes_batch(&ours_nodes).unwrap();
797
798 let mut theirs_nodes = smart_merge_nodes();
799 theirs_nodes[1].body_hash = Some("cb_v2".into());
800 let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
801
802 let result = smart_merge(&base, &ours, &theirs, &edges).unwrap();
803 assert!(!result.has_conflicts()); assert!(result.has_warnings()); assert_eq!(result.warnings.len(), 1);
806 assert_eq!(result.warnings[0].shared_dep, "func:b.py::shared_dep");
807 }
808
809 #[test]
810 fn test_smart_merge_conflict_still_detected() {
811 let base = build_code_nodes_batch(&smart_merge_nodes()).unwrap();
812 let edges = build_code_edges_batch(&smart_merge_edges()).unwrap();
813
814 let mut ours_nodes = smart_merge_nodes();
816 ours_nodes[0].body_hash = Some("ca_ours".into());
817 let ours = build_code_nodes_batch(&ours_nodes).unwrap();
818
819 let mut theirs_nodes = smart_merge_nodes();
820 theirs_nodes[0].body_hash = Some("ca_theirs".into());
821 let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
822
823 let result = smart_merge(&base, &ours, &theirs, &edges).unwrap();
824 assert!(result.has_conflicts());
825 }
826}