use crate::schema::{edge_col, node_col};
use arrow::array::{Array, RecordBatch, StringArray};
use std::collections::HashMap;
#[derive(Debug, thiserror::Error)]
pub enum GitToolError {
#[error("Arrow error: {0}")]
Arrow(#[from] arrow::error::ArrowError),
#[error("No nodes batch provided")]
NoBatch,
}
pub type Result<T> = std::result::Result<T, GitToolError>;
#[derive(Debug, Clone, PartialEq)]
pub struct CodeDiffEntry {
pub node_id: String,
pub kind: String,
pub name: String,
pub change_type: CodeDiffChangeType,
pub old_hash: Option<String>,
pub new_hash: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeDiffChangeType {
Added,
Removed,
Modified,
}
impl std::fmt::Display for CodeDiffChangeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CodeDiffChangeType::Added => f.write_str("added"),
CodeDiffChangeType::Removed => f.write_str("removed"),
CodeDiffChangeType::Modified => f.write_str("modified"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CodeDiffResult {
pub entries: Vec<CodeDiffEntry>,
}
impl CodeDiffResult {
pub fn added(&self) -> Vec<&CodeDiffEntry> {
self.entries
.iter()
.filter(|e| e.change_type == CodeDiffChangeType::Added)
.collect()
}
pub fn removed(&self) -> Vec<&CodeDiffEntry> {
self.entries
.iter()
.filter(|e| e.change_type == CodeDiffChangeType::Removed)
.collect()
}
pub fn modified(&self) -> Vec<&CodeDiffEntry> {
self.entries
.iter()
.filter(|e| e.change_type == CodeDiffChangeType::Modified)
.collect()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn summary(&self) -> String {
format!(
"{} added, {} modified, {} removed",
self.added().len(),
self.modified().len(),
self.removed().len()
)
}
}
#[derive(Debug, Clone)]
pub struct CodeConflict {
pub node_id: String,
pub ours_hash: Option<String>,
pub theirs_hash: Option<String>,
pub base_hash: Option<String>,
}
#[derive(Debug)]
pub struct CodeMergeResult {
pub merged_batch: Option<RecordBatch>,
pub conflicts: Vec<CodeConflict>,
pub clean_merges: usize,
}
impl CodeMergeResult {
pub fn has_conflicts(&self) -> bool {
!self.conflicts.is_empty()
}
}
fn extract_node_hashes(batch: &RecordBatch) -> HashMap<String, NodeSnapshot> {
let mut map = HashMap::new();
if batch.num_rows() == 0 {
return map;
}
let ids = batch
.column(node_col::ID)
.as_any()
.downcast_ref::<StringArray>()
.expect("id column");
let body_hashes = batch
.column(node_col::BODY_HASH)
.as_any()
.downcast_ref::<StringArray>()
.expect("body_hash column");
let names = batch
.column(node_col::NAME)
.as_any()
.downcast_ref::<StringArray>()
.expect("name column");
let kind_col = batch.column(node_col::KIND);
let kind_dict = kind_col
.as_any()
.downcast_ref::<arrow::array::Int8DictionaryArray>()
.expect("kind dict");
let kind_values = kind_dict
.values()
.as_any()
.downcast_ref::<StringArray>()
.expect("kind values");
for i in 0..batch.num_rows() {
let id = ids.value(i).to_string();
let hash = if body_hashes.is_null(i) {
None
} else {
Some(body_hashes.value(i).to_string())
};
let name = names.value(i).to_string();
let kind_key = kind_dict.keys().value(i) as usize;
let kind = kind_values.value(kind_key).to_string();
map.insert(id, NodeSnapshot { hash, name, kind });
}
map
}
#[derive(Debug, Clone)]
struct NodeSnapshot {
hash: Option<String>,
name: String,
kind: String,
}
pub fn codegraph_diff(
base_batch: &RecordBatch,
head_batch: &RecordBatch,
) -> Result<CodeDiffResult> {
let base_map = extract_node_hashes(base_batch);
let head_map = extract_node_hashes(head_batch);
let mut entries = Vec::new();
for (id, head_snap) in &head_map {
match base_map.get(id) {
None => {
entries.push(CodeDiffEntry {
node_id: id.clone(),
kind: head_snap.kind.clone(),
name: head_snap.name.clone(),
change_type: CodeDiffChangeType::Added,
old_hash: None,
new_hash: head_snap.hash.clone(),
});
}
Some(base_snap) => {
if base_snap.hash != head_snap.hash {
entries.push(CodeDiffEntry {
node_id: id.clone(),
kind: head_snap.kind.clone(),
name: head_snap.name.clone(),
change_type: CodeDiffChangeType::Modified,
old_hash: base_snap.hash.clone(),
new_hash: head_snap.hash.clone(),
});
}
}
}
}
for (id, base_snap) in &base_map {
if !head_map.contains_key(id) {
entries.push(CodeDiffEntry {
node_id: id.clone(),
kind: base_snap.kind.clone(),
name: base_snap.name.clone(),
change_type: CodeDiffChangeType::Removed,
old_hash: base_snap.hash.clone(),
new_hash: None,
});
}
}
entries.sort_by(|a, b| a.node_id.cmp(&b.node_id));
Ok(CodeDiffResult { entries })
}
pub fn codegraph_merge(
base_batch: &RecordBatch,
ours_batch: &RecordBatch,
theirs_batch: &RecordBatch,
) -> Result<CodeMergeResult> {
let base_map = extract_node_hashes(base_batch);
let ours_map = extract_node_hashes(ours_batch);
let theirs_map = extract_node_hashes(theirs_batch);
let mut conflicts = Vec::new();
let mut clean_merges = 0usize;
let mut all_ids: Vec<String> = ours_map
.keys()
.chain(theirs_map.keys())
.cloned()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
all_ids.sort();
for id in &all_ids {
let base_hash = base_map.get(id).and_then(|s| s.hash.as_deref());
let ours_hash = ours_map.get(id).and_then(|s| s.hash.as_deref());
let theirs_hash = theirs_map.get(id).and_then(|s| s.hash.as_deref());
let ours_changed = ours_hash != base_hash;
let theirs_changed = theirs_hash != base_hash;
if ours_changed && theirs_changed && ours_hash != theirs_hash {
conflicts.push(CodeConflict {
node_id: id.clone(),
ours_hash: ours_hash.map(|s| s.to_string()),
theirs_hash: theirs_hash.map(|s| s.to_string()),
base_hash: base_hash.map(|s| s.to_string()),
});
} else {
clean_merges += 1;
}
}
let merged_batch = if conflicts.is_empty() {
Some(ours_batch.clone())
} else {
None };
Ok(CodeMergeResult {
merged_batch,
conflicts,
clean_merges,
})
}
#[derive(Debug, Clone)]
pub struct MergeWarning {
pub shared_dep: String,
pub ours_callers: Vec<String>,
pub theirs_callers: Vec<String>,
}
#[derive(Debug)]
pub struct SmartMergeResult {
pub merge: CodeMergeResult,
pub warnings: Vec<MergeWarning>,
}
impl SmartMergeResult {
pub fn has_conflicts(&self) -> bool {
self.merge.has_conflicts()
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
}
pub fn smart_merge(
base_batch: &RecordBatch,
ours_batch: &RecordBatch,
theirs_batch: &RecordBatch,
edges_batch: &RecordBatch,
) -> Result<SmartMergeResult> {
let merge = codegraph_merge(base_batch, ours_batch, theirs_batch)?;
let base_map = extract_node_hashes(base_batch);
let ours_map = extract_node_hashes(ours_batch);
let theirs_map = extract_node_hashes(theirs_batch);
let ours_changed: std::collections::HashSet<String> = ours_map
.iter()
.filter(|(id, snap)| {
base_map
.get(id.as_str())
.map(|b| b.hash != snap.hash)
.unwrap_or(true) })
.map(|(id, _)| id.clone())
.collect();
let theirs_changed: std::collections::HashSet<String> = theirs_map
.iter()
.filter(|(id, snap)| {
base_map
.get(id.as_str())
.map(|b| b.hash != snap.hash)
.unwrap_or(true)
})
.map(|(id, _)| id.clone())
.collect();
let warnings = detect_interaction_warnings(&ours_changed, &theirs_changed, edges_batch);
Ok(SmartMergeResult { merge, warnings })
}
fn detect_interaction_warnings(
ours_changed: &std::collections::HashSet<String>,
theirs_changed: &std::collections::HashSet<String>,
edges_batch: &RecordBatch,
) -> Vec<MergeWarning> {
use crate::schema::CodeEdgePredicate;
if edges_batch.num_rows() == 0 || ours_changed.is_empty() || theirs_changed.is_empty() {
return Vec::new();
}
let sources = edges_batch
.column(edge_col::SOURCE_ID)
.as_any()
.downcast_ref::<StringArray>()
.expect("source_id");
let targets = edges_batch
.column(edge_col::TARGET_ID)
.as_any()
.downcast_ref::<StringArray>()
.expect("target_id");
let pred_dict = edges_batch
.column(edge_col::PREDICATE)
.as_any()
.downcast_ref::<arrow::array::Int8DictionaryArray>()
.expect("predicate dict");
let pred_values = pred_dict
.values()
.as_any()
.downcast_ref::<StringArray>()
.expect("pred values");
let weights = edges_batch
.column(edge_col::WEIGHT)
.as_any()
.downcast_ref::<arrow::array::Float32Array>()
.expect("weight");
let calls_str = CodeEdgePredicate::Calls.as_str();
let mut ours_calls: HashMap<String, Vec<String>> = HashMap::new();
let mut theirs_calls: HashMap<String, Vec<String>> = HashMap::new();
for i in 0..edges_batch.num_rows() {
if !weights.is_null(i) && weights.value(i) < 0.0 {
continue;
}
let pred_key = pred_dict.keys().value(i) as usize;
if pred_values.value(pred_key) != calls_str {
continue;
}
let source = sources.value(i);
let target = targets.value(i).to_string();
if ours_changed.contains(source) {
ours_calls
.entry(target.clone())
.or_default()
.push(source.to_string());
}
if theirs_changed.contains(source) {
theirs_calls
.entry(target)
.or_default()
.push(source.to_string());
}
}
let mut warnings = Vec::new();
for (dep, ours_callers) in &ours_calls {
if let Some(theirs_callers) = theirs_calls.get(dep) {
warnings.push(MergeWarning {
shared_dep: dep.clone(),
ours_callers: ours_callers.clone(),
theirs_callers: theirs_callers.clone(),
});
}
}
warnings.sort_by(|a, b| a.shared_dep.cmp(&b.shared_dep));
warnings
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{CodeNode, CodeNodeKind, build_code_nodes_batch};
fn base_nodes() -> Vec<CodeNode> {
vec![
CodeNode {
id: "func:a.py::foo".into(),
kind: CodeNodeKind::Function,
parent_id: Some("mod:a.py".into()),
name: "foo".into(),
signature: Some("def foo()".into()),
docstring: None,
body_hash: Some("hash_v1".into()),
body: None,
loc: Some(10),
cyclomatic_complexity: Some(2),
coverage_pct: None,
last_modified: None,
..Default::default()
},
CodeNode {
id: "func:a.py::bar".into(),
kind: CodeNodeKind::Function,
parent_id: Some("mod:a.py".into()),
name: "bar".into(),
signature: Some("def bar()".into()),
docstring: None,
body_hash: Some("hash_bar".into()),
body: None,
loc: Some(20),
cyclomatic_complexity: Some(5),
coverage_pct: None,
last_modified: None,
..Default::default()
},
]
}
#[test]
fn test_diff_no_changes() {
let batch = build_code_nodes_batch(&base_nodes()).unwrap();
let result = codegraph_diff(&batch, &batch).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_diff_added_node() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let mut head_nodes = base_nodes();
head_nodes.push(CodeNode {
id: "func:b.py::baz".into(),
kind: CodeNodeKind::Function,
parent_id: None,
name: "baz".into(),
signature: None,
docstring: None,
body_hash: Some("hash_new".into()),
body: None,
loc: None,
cyclomatic_complexity: None,
coverage_pct: None,
last_modified: None,
..Default::default()
});
let head = build_code_nodes_batch(&head_nodes).unwrap();
let result = codegraph_diff(&base, &head).unwrap();
assert_eq!(result.added().len(), 1);
assert_eq!(result.added()[0].name, "baz");
assert_eq!(result.modified().len(), 0);
assert_eq!(result.removed().len(), 0);
}
#[test]
fn test_diff_removed_node() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let head_nodes = vec![base_nodes()[0].clone()]; let head = build_code_nodes_batch(&head_nodes).unwrap();
let result = codegraph_diff(&base, &head).unwrap();
assert_eq!(result.removed().len(), 1);
assert_eq!(result.removed()[0].name, "bar");
}
#[test]
fn test_diff_modified_node() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let mut head_nodes = base_nodes();
head_nodes[0].body_hash = Some("hash_v2".into()); let head = build_code_nodes_batch(&head_nodes).unwrap();
let result = codegraph_diff(&base, &head).unwrap();
assert_eq!(result.modified().len(), 1);
assert_eq!(result.modified()[0].name, "foo");
assert_eq!(result.modified()[0].old_hash, Some("hash_v1".into()));
assert_eq!(result.modified()[0].new_hash, Some("hash_v2".into()));
}
#[test]
fn test_diff_summary() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let mut head_nodes = base_nodes();
head_nodes[0].body_hash = Some("hash_v2".into());
head_nodes.push(CodeNode {
id: "func:c.py::new_func".into(),
kind: CodeNodeKind::Function,
parent_id: None,
name: "new_func".into(),
signature: None,
docstring: None,
body_hash: Some("new_hash".into()),
body: None,
loc: None,
cyclomatic_complexity: None,
coverage_pct: None,
last_modified: None,
..Default::default()
});
let head = build_code_nodes_batch(&head_nodes).unwrap();
let result = codegraph_diff(&base, &head).unwrap();
assert_eq!(result.summary(), "1 added, 1 modified, 0 removed");
}
#[test]
fn test_diff_empty_batches() {
let empty = build_code_nodes_batch(&[]).unwrap();
let result = codegraph_diff(&empty, &empty).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_merge_no_changes() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let result = codegraph_merge(&base, &base, &base).unwrap();
assert!(!result.has_conflicts());
assert!(result.merged_batch.is_some());
}
#[test]
fn test_merge_one_side_changes() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let mut ours_nodes = base_nodes();
ours_nodes[0].body_hash = Some("ours_hash".into());
let ours = build_code_nodes_batch(&ours_nodes).unwrap();
let result = codegraph_merge(&base, &ours, &base).unwrap();
assert!(!result.has_conflicts());
assert!(result.merged_batch.is_some());
}
#[test]
fn test_merge_both_sides_same_change() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let mut changed = base_nodes();
changed[0].body_hash = Some("same_hash".into());
let ours = build_code_nodes_batch(&changed).unwrap();
let theirs = build_code_nodes_batch(&changed).unwrap();
let result = codegraph_merge(&base, &ours, &theirs).unwrap();
assert!(!result.has_conflicts()); }
#[test]
fn test_merge_conflict() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let mut ours_nodes = base_nodes();
ours_nodes[0].body_hash = Some("ours_hash".into());
let ours = build_code_nodes_batch(&ours_nodes).unwrap();
let mut theirs_nodes = base_nodes();
theirs_nodes[0].body_hash = Some("theirs_hash".into());
let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
let result = codegraph_merge(&base, &ours, &theirs).unwrap();
assert!(result.has_conflicts());
assert_eq!(result.conflicts.len(), 1);
assert_eq!(result.conflicts[0].node_id, "func:a.py::foo");
assert_eq!(result.conflicts[0].ours_hash, Some("ours_hash".into()));
assert_eq!(result.conflicts[0].theirs_hash, Some("theirs_hash".into()));
assert_eq!(result.conflicts[0].base_hash, Some("hash_v1".into()));
assert!(result.merged_batch.is_none()); }
#[test]
fn test_merge_conflict_reports_to_agent() {
let base = build_code_nodes_batch(&base_nodes()).unwrap();
let mut ours_nodes = base_nodes();
ours_nodes[0].body_hash = Some("v_ours".into());
ours_nodes[1].body_hash = Some("v_ours_bar".into());
let ours = build_code_nodes_batch(&ours_nodes).unwrap();
let mut theirs_nodes = base_nodes();
theirs_nodes[0].body_hash = Some("v_theirs".into());
let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
let result = codegraph_merge(&base, &ours, &theirs).unwrap();
assert_eq!(result.conflicts.len(), 1); assert_eq!(result.conflicts[0].node_id, "func:a.py::foo");
}
use crate::schema::{CodeEdge, CodeEdgePredicate, build_code_edges_batch};
fn smart_merge_nodes() -> Vec<CodeNode> {
vec![
CodeNode {
id: "func:a.py::caller_a".into(),
kind: CodeNodeKind::Function,
parent_id: None,
name: "caller_a".into(),
signature: None,
docstring: None,
body_hash: Some("ca_v1".into()),
body: None,
loc: Some(10),
cyclomatic_complexity: None,
coverage_pct: None,
last_modified: None,
..Default::default()
},
CodeNode {
id: "func:a.py::caller_b".into(),
kind: CodeNodeKind::Function,
parent_id: None,
name: "caller_b".into(),
signature: None,
docstring: None,
body_hash: Some("cb_v1".into()),
body: None,
loc: Some(10),
cyclomatic_complexity: None,
coverage_pct: None,
last_modified: None,
..Default::default()
},
CodeNode {
id: "func:b.py::shared_dep".into(),
kind: CodeNodeKind::Function,
parent_id: None,
name: "shared_dep".into(),
signature: None,
docstring: None,
body_hash: Some("sd_v1".into()),
body: None,
loc: Some(20),
cyclomatic_complexity: None,
coverage_pct: None,
last_modified: None,
..Default::default()
},
]
}
fn smart_merge_edges() -> Vec<CodeEdge> {
vec![
CodeEdge {
source_id: "func:a.py::caller_a".into(),
target_id: "func:b.py::shared_dep".into(),
predicate: CodeEdgePredicate::Calls,
weight: Some(1.0),
commit_id: None,
},
CodeEdge {
source_id: "func:a.py::caller_b".into(),
target_id: "func:b.py::shared_dep".into(),
predicate: CodeEdgePredicate::Calls,
weight: Some(1.0),
commit_id: None,
},
]
}
#[test]
fn test_smart_merge_no_interaction() {
let base = build_code_nodes_batch(&smart_merge_nodes()).unwrap();
let edges = build_code_edges_batch(&smart_merge_edges()).unwrap();
let mut ours_nodes = smart_merge_nodes();
ours_nodes[0].body_hash = Some("ca_v2".into());
let ours = build_code_nodes_batch(&ours_nodes).unwrap();
let result = smart_merge(&base, &ours, &base, &edges).unwrap();
assert!(!result.has_conflicts());
assert!(!result.has_warnings());
}
#[test]
fn test_smart_merge_interaction_warning() {
let base = build_code_nodes_batch(&smart_merge_nodes()).unwrap();
let edges = build_code_edges_batch(&smart_merge_edges()).unwrap();
let mut ours_nodes = smart_merge_nodes();
ours_nodes[0].body_hash = Some("ca_v2".into());
let ours = build_code_nodes_batch(&ours_nodes).unwrap();
let mut theirs_nodes = smart_merge_nodes();
theirs_nodes[1].body_hash = Some("cb_v2".into());
let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
let result = smart_merge(&base, &ours, &theirs, &edges).unwrap();
assert!(!result.has_conflicts()); assert!(result.has_warnings()); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].shared_dep, "func:b.py::shared_dep");
}
#[test]
fn test_smart_merge_conflict_still_detected() {
let base = build_code_nodes_batch(&smart_merge_nodes()).unwrap();
let edges = build_code_edges_batch(&smart_merge_edges()).unwrap();
let mut ours_nodes = smart_merge_nodes();
ours_nodes[0].body_hash = Some("ca_ours".into());
let ours = build_code_nodes_batch(&ours_nodes).unwrap();
let mut theirs_nodes = smart_merge_nodes();
theirs_nodes[0].body_hash = Some("ca_theirs".into());
let theirs = build_code_nodes_batch(&theirs_nodes).unwrap();
let result = smart_merge(&base, &ours, &theirs, &edges).unwrap();
assert!(result.has_conflicts());
}
}