Skip to main content

nusy_codegraph/
git_tools.rs

1//! Git-integrated MCP tools for CodeGraph versioning.
2//!
3//! Provides 4 tools that integrate with `nusy-arrow-git`:
4//! - `codegraph_commit` — snapshot CodeGraph state to Parquet
5//! - `codegraph_checkout` — restore CodeGraph state from a commit
6//! - `codegraph_diff` — object-level diff between CodeGraph states
7//! - `codegraph_merge` — 3-way merge with conflict detection
8//!
9//! These operate on CodeNode/CodeEdge RecordBatches, delegating persistence
10//! and versioning to `nusy-arrow-git` primitives.
11
12use crate::schema::{edge_col, node_col};
13use arrow::array::{Array, RecordBatch, StringArray};
14use std::collections::HashMap;
15
16/// Errors from git tool operations.
17#[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/// A single entry in a CodeGraph diff.
29#[derive(Debug, Clone, PartialEq)]
30pub struct CodeDiffEntry {
31    /// The CodeNode ID (e.g., "func:brain/signal_fusion.py::fuse").
32    pub node_id: String,
33    /// The kind of object.
34    pub kind: String,
35    /// The name of the object.
36    pub name: String,
37    /// What changed.
38    pub change_type: CodeDiffChangeType,
39    /// Old body hash (None for added nodes).
40    pub old_hash: Option<String>,
41    /// New body hash (None for removed nodes).
42    pub new_hash: Option<String>,
43}
44
45/// The type of change in a diff.
46#[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/// Result of a CodeGraph diff.
64#[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/// A conflict detected during merge.
106#[derive(Debug, Clone)]
107pub struct CodeConflict {
108    /// The CodeNode ID that conflicts.
109    pub node_id: String,
110    /// Hash from the "ours" side.
111    pub ours_hash: Option<String>,
112    /// Hash from the "theirs" side.
113    pub theirs_hash: Option<String>,
114    /// Hash from the common ancestor (base).
115    pub base_hash: Option<String>,
116}
117
118/// Result of a 3-way merge.
119#[derive(Debug)]
120pub struct CodeMergeResult {
121    /// The merged batch (if no conflicts, or with auto-resolved changes).
122    pub merged_batch: Option<RecordBatch>,
123    /// Conflicts that need manual resolution.
124    pub conflicts: Vec<CodeConflict>,
125    /// Nodes that were cleanly merged (no conflict).
126    pub clean_merges: usize,
127}
128
129impl CodeMergeResult {
130    pub fn has_conflicts(&self) -> bool {
131        !self.conflicts.is_empty()
132    }
133}
134
135/// Extract a map of node_id → body_hash from a CodeNodes batch.
136fn 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    // Extract kind from dictionary
160    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
195/// Compute an object-level diff between two CodeNodes batches.
196///
197/// Compares by body_hash — if the hash changed, the node was modified.
198/// Nodes present in `head` but not `base` are added.
199/// Nodes present in `base` but not `head` are removed.
200pub 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    // Added and modified
210    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    // Removed
238    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    // Sort for deterministic output
252    entries.sort_by(|a, b| a.node_id.cmp(&b.node_id));
253
254    Ok(CodeDiffResult { entries })
255}
256
257/// 3-way merge of CodeNodes batches.
258///
259/// `base` is the common ancestor. `ours` and `theirs` are the two branches.
260/// If both branches modify the same node differently, it's a conflict.
261/// If only one branch modifies a node, the modification wins.
262pub 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    // Check all nodes that exist in either branch
275    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        // If both changed from base AND they disagree, it's a conflict
290        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    // V14.0: Return ours_batch as the merged result when no conflicts.
306    // TODO: Build a proper merged batch that includes non-conflicting changes
307    // from both sides. Currently, if ours modifies A and theirs adds C,
308    // the result is ours (A',B) — missing C. This is acceptable for V14.0
309    // where merges are rare and agents can re-apply missing changes.
310    // Full merge implementation deferred to EXP-1263 Phase 2 follow-up.
311    let merged_batch = if conflicts.is_empty() {
312        Some(ours_batch.clone())
313    } else {
314        None // Conflicts need manual resolution by the agent
315    };
316
317    Ok(CodeMergeResult {
318        merged_batch,
319        conflicts,
320        clean_merges,
321    })
322}
323
324// ─── Smart merge: semantic conflict detection with interaction warnings ──────
325
326/// A warning about potential interaction between changes.
327#[derive(Debug, Clone)]
328pub struct MergeWarning {
329    /// The shared dependency that both sides' changes touch callers of.
330    pub shared_dep: String,
331    /// Node IDs changed on the "ours" side that call the shared dep.
332    pub ours_callers: Vec<String>,
333    /// Node IDs changed on the "theirs" side that call the shared dep.
334    pub theirs_callers: Vec<String>,
335}
336
337/// Result of a smart merge (standard merge + interaction warnings).
338#[derive(Debug)]
339pub struct SmartMergeResult {
340    /// The standard merge result.
341    pub merge: CodeMergeResult,
342    /// Interaction warnings (non-blocking — both sides modify callers of same dep).
343    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
356/// Smart 3-way merge with interaction warning detection.
357///
358/// Wraps `codegraph_merge` and additionally detects when both branches modify
359/// different callers of a shared dependency — a potential interaction risk
360/// even though there's no direct conflict.
361pub 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    // Compute which nodes each side changed (relative to base)
370    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) // added in ours
381        })
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    // Build call target map: for each changed node, what does it call?
397    let warnings = detect_interaction_warnings(&ours_changed, &theirs_changed, edges_batch);
398
399    Ok(SmartMergeResult { merge, warnings })
400}
401
402/// Detect interaction warnings: both sides modify callers of the same dependency.
403fn 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    // Build: target → set of callers (from changed nodes only)
443    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    // Find shared deps: targets called by both ours and theirs changed nodes
473    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    // --- Diff tests ---
529
530    #[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()]; // Only keep first
569        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()); // Modified
581        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    // --- Merge tests ---
624
625    #[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()); // Same change = no conflict
655    }
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()); // Can't merge with conflicts
676    }
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        // theirs_nodes[1] unchanged — should not conflict
689        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); // Only foo conflicts
693        assert_eq!(result.conflicts[0].node_id, "func:a.py::foo");
694        // bar was only changed on ours side — clean merge
695    }
696
697    // --- Smart merge tests ---
698
699    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            // caller_a → shared_dep
754            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            // caller_b → shared_dep
762            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        // Only ours changes caller_a — no interaction
778        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        // Ours changes caller_a, theirs changes caller_b
793        // Both call shared_dep — interaction warning
794        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()); // Different nodes changed — no conflict
804        assert!(result.has_warnings()); // Both modify callers of shared_dep
805        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        // Both sides modify the SAME node → conflict (not just warning)
815        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}