use crate::checkout;
use crate::commit::{Commit, CommitError, CommitsTable, create_commit};
use crate::diff::{self, DiffEntry};
use crate::history::find_common_ancestor;
use crate::object_store::GitObjectStore;
use nusy_arrow_core::{Namespace, Triple, YLayer, col};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct Conflict {
pub subject: String,
pub predicate: String,
pub namespace: String,
pub object_a: String,
pub object_b: String,
}
#[derive(Debug)]
pub enum MergeResult {
Clean(Commit),
Conflict(Vec<Conflict>),
NoCommonAncestor,
}
#[derive(Debug, thiserror::Error)]
pub enum MergeError {
#[error("Commit error: {0}")]
Commit(#[from] CommitError),
#[error("Store error: {0}")]
Store(#[from] nusy_arrow_core::StoreError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
KeepOurs,
KeepTheirs,
KeepBoth,
Drop,
}
pub enum MergeStrategy {
Manual,
Ours,
Theirs,
LastWriterWins,
Custom(Box<dyn Fn(&Conflict) -> Resolution>),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ConflictKey {
subject: String,
predicate: String,
namespace: String,
}
pub fn merge(
obj_store: &mut GitObjectStore,
commits_table: &mut CommitsTable,
commit_a_id: &str,
commit_b_id: &str,
author: &str,
) -> Result<MergeResult, MergeError> {
let ancestor = match find_common_ancestor(commits_table, commit_a_id, commit_b_id) {
Some(a) => a.commit_id.clone(),
None => return Ok(MergeResult::NoCommonAncestor),
};
let diff_a = diff::diff(obj_store, commits_table, &ancestor, commit_a_id)?;
let diff_b = diff::diff(obj_store, commits_table, &ancestor, commit_b_id)?;
let a_adds: HashMap<ConflictKey, &DiffEntry> = diff_a
.added
.iter()
.map(|e| {
(
ConflictKey {
subject: e.subject.clone(),
predicate: e.predicate.clone(),
namespace: e.namespace.clone(),
},
e,
)
})
.collect();
let mut conflicts = Vec::new();
for entry_b in &diff_b.added {
let key = ConflictKey {
subject: entry_b.subject.clone(),
predicate: entry_b.predicate.clone(),
namespace: entry_b.namespace.clone(),
};
if let Some(entry_a) = a_adds.get(&key)
&& entry_a.object != entry_b.object
{
conflicts.push(Conflict {
subject: key.subject,
predicate: key.predicate,
namespace: key.namespace,
object_a: entry_a.object.clone(),
object_b: entry_b.object.clone(),
});
}
}
if !conflicts.is_empty() {
return Ok(MergeResult::Conflict(conflicts));
}
apply_clean_merge(
obj_store,
commits_table,
&ancestor,
commit_a_id,
commit_b_id,
&diff_a,
&diff_b,
author,
)
}
pub fn merge_with_strategy(
obj_store: &mut GitObjectStore,
commits_table: &mut CommitsTable,
commit_a_id: &str,
commit_b_id: &str,
author: &str,
strategy: &MergeStrategy,
) -> Result<MergeResult, MergeError> {
let ancestor = match find_common_ancestor(commits_table, commit_a_id, commit_b_id) {
Some(a) => a.commit_id.clone(),
None => return Ok(MergeResult::NoCommonAncestor),
};
let diff_a = diff::diff(obj_store, commits_table, &ancestor, commit_a_id)?;
let diff_b = diff::diff(obj_store, commits_table, &ancestor, commit_b_id)?;
let a_adds: HashMap<ConflictKey, &DiffEntry> = diff_a
.added
.iter()
.map(|e| {
(
ConflictKey {
subject: e.subject.clone(),
predicate: e.predicate.clone(),
namespace: e.namespace.clone(),
},
e,
)
})
.collect();
let b_adds: HashMap<ConflictKey, &DiffEntry> = diff_b
.added
.iter()
.map(|e| {
(
ConflictKey {
subject: e.subject.clone(),
predicate: e.predicate.clone(),
namespace: e.namespace.clone(),
},
e,
)
})
.collect();
let mut conflicts = Vec::new();
for (key, entry_b) in &b_adds {
if let Some(entry_a) = a_adds.get(key)
&& entry_a.object != entry_b.object
{
conflicts.push(Conflict {
subject: key.subject.clone(),
predicate: key.predicate.clone(),
namespace: key.namespace.clone(),
object_a: entry_a.object.clone(),
object_b: entry_b.object.clone(),
});
}
}
if conflicts.is_empty() {
return apply_clean_merge(
obj_store,
commits_table,
&ancestor,
commit_a_id,
commit_b_id,
&diff_a,
&diff_b,
author,
);
}
if matches!(strategy, MergeStrategy::Manual) {
return Ok(MergeResult::Conflict(conflicts));
}
let mut resolved_keys: HashMap<ConflictKey, Resolution> = HashMap::new();
for conflict in &conflicts {
let resolution = match strategy {
MergeStrategy::Manual => unreachable!(),
MergeStrategy::Ours => Resolution::KeepOurs,
MergeStrategy::Theirs => Resolution::KeepTheirs,
MergeStrategy::LastWriterWins => {
let key = ConflictKey {
subject: conflict.subject.clone(),
predicate: conflict.predicate.clone(),
namespace: conflict.namespace.clone(),
};
let ts_a = a_adds
.get(&key)
.and_then(|e| e.consolidated_at)
.unwrap_or(0);
let ts_b = b_adds
.get(&key)
.and_then(|e| e.consolidated_at)
.unwrap_or(0);
if ts_a >= ts_b {
Resolution::KeepOurs
} else {
Resolution::KeepTheirs
}
}
MergeStrategy::Custom(f) => f(conflict),
};
resolved_keys.insert(
ConflictKey {
subject: conflict.subject.clone(),
predicate: conflict.predicate.clone(),
namespace: conflict.namespace.clone(),
},
resolution,
);
}
checkout::checkout(obj_store, commits_table, &ancestor)?;
let mut all_adds: HashMap<(String, String, String, String), &DiffEntry> = HashMap::new();
for entry in diff_a.added.iter().chain(diff_b.added.iter()) {
let conflict_key = ConflictKey {
subject: entry.subject.clone(),
predicate: entry.predicate.clone(),
namespace: entry.namespace.clone(),
};
if let Some(resolution) = resolved_keys.get(&conflict_key) {
let spo_key = (
entry.subject.clone(),
entry.predicate.clone(),
entry.object.clone(),
entry.namespace.clone(),
);
match resolution {
Resolution::KeepOurs => {
if a_adds.contains_key(&conflict_key)
&& a_adds.get(&conflict_key).map(|e| &e.object) == Some(&entry.object)
{
all_adds.insert(spo_key, entry);
}
}
Resolution::KeepTheirs => {
if b_adds.contains_key(&conflict_key)
&& b_adds.get(&conflict_key).map(|e| &e.object) == Some(&entry.object)
{
all_adds.insert(spo_key, entry);
}
}
Resolution::KeepBoth => {
all_adds.insert(spo_key, entry);
}
Resolution::Drop => {
}
}
} else {
let key = (
entry.subject.clone(),
entry.predicate.clone(),
entry.object.clone(),
entry.namespace.clone(),
);
all_adds.entry(key).or_insert(entry);
}
}
for entry in all_adds.values() {
let ns = Namespace::from_str_loose(&entry.namespace).unwrap_or(Namespace::World);
let y_layer = YLayer::from_u8(entry.y_layer).unwrap_or(YLayer::Semantic);
let triple = Triple {
subject: entry.subject.clone(),
predicate: entry.predicate.clone(),
object: entry.object.clone(),
graph: entry.graph.clone(),
confidence: entry.confidence,
source_document: entry.source_document.clone(),
source_chunk_id: entry.source_chunk_id.clone(),
extracted_by: Some(format!("merge by {author}")),
caused_by: entry.caused_by.clone(),
derived_from: entry.derived_from.clone(),
consolidated_at: entry.consolidated_at,
certifiability_class: entry.certifiability_class.clone(),
};
obj_store.store.add_triple(&triple, ns, y_layer)?;
}
apply_removals(obj_store, &diff_a, &diff_b);
let merge_commit = create_commit(
obj_store,
commits_table,
vec![commit_a_id.to_string(), commit_b_id.to_string()],
&format!("Merge {} into {} (resolved)", commit_b_id, commit_a_id),
author,
)?;
Ok(MergeResult::Clean(merge_commit))
}
#[allow(clippy::too_many_arguments)]
fn apply_clean_merge(
obj_store: &mut GitObjectStore,
commits_table: &mut CommitsTable,
ancestor: &str,
commit_a_id: &str,
commit_b_id: &str,
diff_a: &diff::DiffResult,
diff_b: &diff::DiffResult,
author: &str,
) -> Result<MergeResult, MergeError> {
checkout::checkout(obj_store, commits_table, ancestor)?;
let mut all_adds: HashMap<(String, String, String, String), &DiffEntry> = HashMap::new();
for entry in diff_a.added.iter().chain(diff_b.added.iter()) {
let key = (
entry.subject.clone(),
entry.predicate.clone(),
entry.object.clone(),
entry.namespace.clone(),
);
all_adds.entry(key).or_insert(entry);
}
for entry in all_adds.values() {
let ns = Namespace::from_str_loose(&entry.namespace).unwrap_or(Namespace::World);
let y_layer = YLayer::from_u8(entry.y_layer).unwrap_or(YLayer::Semantic);
let triple = Triple {
subject: entry.subject.clone(),
predicate: entry.predicate.clone(),
object: entry.object.clone(),
graph: entry.graph.clone(),
confidence: entry.confidence,
source_document: entry.source_document.clone(),
source_chunk_id: entry.source_chunk_id.clone(),
extracted_by: Some(format!("merge by {author}")),
caused_by: entry.caused_by.clone(),
derived_from: entry.derived_from.clone(),
consolidated_at: entry.consolidated_at,
certifiability_class: entry.certifiability_class.clone(),
};
obj_store.store.add_triple(&triple, ns, y_layer)?;
}
apply_removals(obj_store, diff_a, diff_b);
let merge_commit = create_commit(
obj_store,
commits_table,
vec![commit_a_id.to_string(), commit_b_id.to_string()],
&format!("Merge {} into {}", commit_b_id, commit_a_id),
author,
)?;
Ok(MergeResult::Clean(merge_commit))
}
fn apply_removals(
obj_store: &mut GitObjectStore,
diff_a: &diff::DiffResult,
diff_b: &diff::DiffResult,
) {
let all_removals: HashSet<(String, String, String, String)> = diff_a
.removed
.iter()
.chain(diff_b.removed.iter())
.map(|e| {
(
e.subject.clone(),
e.predicate.clone(),
e.object.clone(),
e.namespace.clone(),
)
})
.collect();
for ns in Namespace::ALL {
let batches = obj_store.store.get_namespace_batches(ns);
let ns_str = ns.as_str().to_string();
let mut ids_to_delete = Vec::new();
for batch in batches {
let id_col = batch
.column(col::TRIPLE_ID)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.expect("triple_id column");
let subj_col = batch
.column(col::SUBJECT)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.expect("subject column");
let pred_col = batch
.column(col::PREDICATE)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.expect("predicate column");
let obj_col = batch
.column(col::OBJECT)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.expect("object column");
for i in 0..batch.num_rows() {
let key = (
subj_col.value(i).to_string(),
pred_col.value(i).to_string(),
obj_col.value(i).to_string(),
ns_str.clone(),
);
if all_removals.contains(&key) {
ids_to_delete.push(id_col.value(i).to_string());
}
}
}
for id in &ids_to_delete {
let _ = obj_store.store.delete(id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commit::create_commit;
fn sample_triple(subj: &str, obj: &str) -> Triple {
Triple {
subject: subj.to_string(),
predicate: "rdf:type".to_string(),
object: obj.to_string(),
graph: None,
confidence: Some(0.9),
source_document: None,
source_chunk_id: None,
extracted_by: None,
caused_by: None,
derived_from: None,
consolidated_at: None,
certifiability_class: None,
}
}
#[test]
fn test_non_conflicting_merge() {
let tmp = tempfile::tempdir().unwrap();
let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
let mut commits = CommitsTable::new();
obj.store
.add_triple(
&sample_triple("base", "Base"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let base = create_commit(&obj, &mut commits, vec![], "base", "DGX").unwrap();
obj.store
.add_triple(
&sample_triple("a-only", "A"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let ca = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"branch-a",
"DGX",
)
.unwrap();
checkout::checkout(&mut obj, &commits, &base.commit_id).unwrap();
obj.store
.add_triple(
&sample_triple("b-only", "B"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let cb = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"branch-b",
"DGX",
)
.unwrap();
let result = merge(&mut obj, &mut commits, &ca.commit_id, &cb.commit_id, "DGX").unwrap();
match result {
MergeResult::Clean(mc) => {
assert_eq!(mc.parent_ids.len(), 2);
assert!(obj.store.len() >= 3);
}
MergeResult::Conflict(_) => panic!("Expected clean merge"),
MergeResult::NoCommonAncestor => panic!("Expected common ancestor"),
}
}
#[test]
fn test_conflicting_merge() {
let tmp = tempfile::tempdir().unwrap();
let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
let mut commits = CommitsTable::new();
obj.store
.add_triple(
&sample_triple("base", "Base"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let base = create_commit(&obj, &mut commits, vec![], "base", "DGX").unwrap();
obj.store
.add_triple(
&sample_triple("conflict-subj", "TypeA"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let ca = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"branch-a",
"DGX",
)
.unwrap();
checkout::checkout(&mut obj, &commits, &base.commit_id).unwrap();
obj.store
.add_triple(
&sample_triple("conflict-subj", "TypeB"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let cb = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"branch-b",
"DGX",
)
.unwrap();
let result = merge(&mut obj, &mut commits, &ca.commit_id, &cb.commit_id, "DGX").unwrap();
match result {
MergeResult::Conflict(conflicts) => {
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].subject, "conflict-subj");
assert_eq!(conflicts[0].object_a, "TypeA");
assert_eq!(conflicts[0].object_b, "TypeB");
}
_ => panic!("Expected conflict"),
}
}
#[test]
fn test_merge_commit_has_two_parents() {
let tmp = tempfile::tempdir().unwrap();
let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
let mut commits = CommitsTable::new();
obj.store
.add_triple(
&sample_triple("base", "Base"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let base = create_commit(&obj, &mut commits, vec![], "base", "DGX").unwrap();
obj.store
.add_triple(&sample_triple("a", "A"), Namespace::World, YLayer::Semantic)
.unwrap();
let ca =
create_commit(&obj, &mut commits, vec![base.commit_id.clone()], "a", "DGX").unwrap();
checkout::checkout(&mut obj, &commits, &base.commit_id).unwrap();
obj.store
.add_triple(&sample_triple("b", "B"), Namespace::Work, YLayer::Semantic)
.unwrap();
let cb =
create_commit(&obj, &mut commits, vec![base.commit_id.clone()], "b", "DGX").unwrap();
let result = merge(&mut obj, &mut commits, &ca.commit_id, &cb.commit_id, "DGX").unwrap();
match result {
MergeResult::Clean(mc) => {
assert_eq!(mc.parent_ids.len(), 2);
assert!(mc.parent_ids.contains(&ca.commit_id));
assert!(mc.parent_ids.contains(&cb.commit_id));
}
_ => panic!("Expected clean merge"),
}
}
fn setup_conflict_scenario() -> (GitObjectStore, CommitsTable, String, String) {
let tmp = tempfile::tempdir().unwrap();
let tmp_path = tmp.path().to_owned();
std::mem::forget(tmp);
let mut obj = GitObjectStore::with_snapshot_dir(&tmp_path);
let mut commits = CommitsTable::new();
obj.store
.add_triple(
&sample_triple("base", "Base"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let base = create_commit(&obj, &mut commits, vec![], "base", "DGX").unwrap();
obj.store
.add_triple(
&sample_triple("conflict-subj", "TypeA"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let ca = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"branch-a",
"DGX",
)
.unwrap();
checkout::checkout(&mut obj, &commits, &base.commit_id).unwrap();
obj.store
.add_triple(
&sample_triple("conflict-subj", "TypeB"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let cb = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"branch-b",
"DGX",
)
.unwrap();
(obj, commits, ca.commit_id, cb.commit_id)
}
#[test]
fn test_strategy_manual_returns_conflict() {
let (mut obj, mut commits, ca_id, cb_id) = setup_conflict_scenario();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca_id,
&cb_id,
"DGX",
&MergeStrategy::Manual,
)
.unwrap();
match result {
MergeResult::Conflict(conflicts) => {
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].subject, "conflict-subj");
}
_ => panic!("Manual strategy should return Conflict"),
}
}
#[test]
fn test_strategy_ours() {
let (mut obj, mut commits, ca_id, cb_id) = setup_conflict_scenario();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca_id,
&cb_id,
"DGX",
&MergeStrategy::Ours,
)
.unwrap();
match result {
MergeResult::Clean(_) => {
let batches = obj
.store
.query(&nusy_arrow_core::QuerySpec {
subject: Some("conflict-subj".to_string()),
..Default::default()
})
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 1, "Should have exactly one triple for conflict-subj");
let batch = &batches[0];
let obj_col = batch
.column(col::OBJECT)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.unwrap();
assert_eq!(obj_col.value(0), "TypeA");
}
_ => panic!("Ours strategy should produce Clean merge"),
}
}
#[test]
fn test_strategy_theirs() {
let (mut obj, mut commits, ca_id, cb_id) = setup_conflict_scenario();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca_id,
&cb_id,
"DGX",
&MergeStrategy::Theirs,
)
.unwrap();
match result {
MergeResult::Clean(_) => {
let batches = obj
.store
.query(&nusy_arrow_core::QuerySpec {
subject: Some("conflict-subj".to_string()),
..Default::default()
})
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 1, "Should have exactly one triple for conflict-subj");
let batch = &batches[0];
let obj_col = batch
.column(col::OBJECT)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.unwrap();
assert_eq!(obj_col.value(0), "TypeB");
}
_ => panic!("Theirs strategy should produce Clean merge"),
}
}
#[test]
fn test_strategy_keep_both() {
let (mut obj, mut commits, ca_id, cb_id) = setup_conflict_scenario();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca_id,
&cb_id,
"DGX",
&MergeStrategy::Custom(Box::new(|_| Resolution::KeepBoth)),
)
.unwrap();
match result {
MergeResult::Clean(_) => {
let batches = obj
.store
.query(&nusy_arrow_core::QuerySpec {
subject: Some("conflict-subj".to_string()),
..Default::default()
})
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 2, "KeepBoth should preserve both triples");
}
_ => panic!("Custom(KeepBoth) strategy should produce Clean merge"),
}
}
#[test]
fn test_strategy_drop() {
let (mut obj, mut commits, ca_id, cb_id) = setup_conflict_scenario();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca_id,
&cb_id,
"DGX",
&MergeStrategy::Custom(Box::new(|_| Resolution::Drop)),
)
.unwrap();
match result {
MergeResult::Clean(_) => {
let batches = obj
.store
.query(&nusy_arrow_core::QuerySpec {
subject: Some("conflict-subj".to_string()),
..Default::default()
})
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 0, "Drop should remove both conflicting triples");
}
_ => panic!("Custom(Drop) strategy should produce Clean merge"),
}
}
#[test]
fn test_strategy_last_writer_wins() {
let tmp = tempfile::tempdir().unwrap();
let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
let mut commits = CommitsTable::new();
obj.store
.add_triple(
&sample_triple("base", "Base"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let base = create_commit(&obj, &mut commits, vec![], "base", "DGX").unwrap();
let triple_a = Triple {
subject: "ts-subj".to_string(),
predicate: "rdf:type".to_string(),
object: "OlderValue".to_string(),
graph: None,
confidence: Some(0.9),
source_document: None,
source_chunk_id: None,
extracted_by: None,
caused_by: None,
derived_from: None,
consolidated_at: Some(1000), certifiability_class: None,
};
obj.store
.add_triple(&triple_a, Namespace::World, YLayer::Semantic)
.unwrap();
let ca = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"older",
"DGX",
)
.unwrap();
checkout::checkout(&mut obj, &commits, &base.commit_id).unwrap();
let triple_b = Triple {
subject: "ts-subj".to_string(),
predicate: "rdf:type".to_string(),
object: "NewerValue".to_string(),
graph: None,
confidence: Some(0.9),
source_document: None,
source_chunk_id: None,
extracted_by: None,
caused_by: None,
derived_from: None,
consolidated_at: Some(2000), certifiability_class: None,
};
obj.store
.add_triple(&triple_b, Namespace::World, YLayer::Semantic)
.unwrap();
let cb = create_commit(
&obj,
&mut commits,
vec![base.commit_id.clone()],
"newer",
"DGX",
)
.unwrap();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca.commit_id,
&cb.commit_id,
"DGX",
&MergeStrategy::LastWriterWins,
)
.unwrap();
match result {
MergeResult::Clean(_) => {
let batches = obj
.store
.query(&nusy_arrow_core::QuerySpec {
subject: Some("ts-subj".to_string()),
..Default::default()
})
.unwrap();
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 1);
let batch = &batches[0];
let obj_col = batch
.column(col::OBJECT)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.unwrap();
assert_eq!(
obj_col.value(0),
"NewerValue",
"LastWriterWins should pick the newer timestamp"
);
}
_ => panic!("LastWriterWins should produce Clean merge"),
}
}
#[test]
fn test_strategy_custom_conditional() {
let (mut obj, mut commits, ca_id, cb_id) = setup_conflict_scenario();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca_id,
&cb_id,
"DGX",
&MergeStrategy::Custom(Box::new(|c| {
if c.subject.starts_with("conflict") {
Resolution::KeepOurs
} else {
Resolution::KeepTheirs
}
})),
)
.unwrap();
match result {
MergeResult::Clean(_) => {
let batches = obj
.store
.query(&nusy_arrow_core::QuerySpec {
subject: Some("conflict-subj".to_string()),
..Default::default()
})
.unwrap();
let batch = &batches[0];
let obj_col = batch
.column(col::OBJECT)
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.unwrap();
assert_eq!(
obj_col.value(0),
"TypeA",
"Custom should keep ours for conflict-* subjects"
);
}
_ => panic!("Custom strategy should produce Clean merge"),
}
}
#[test]
fn test_strategy_on_no_conflict_still_clean() {
let tmp = tempfile::tempdir().unwrap();
let mut obj = GitObjectStore::with_snapshot_dir(tmp.path());
let mut commits = CommitsTable::new();
obj.store
.add_triple(
&sample_triple("base", "Base"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let base = create_commit(&obj, &mut commits, vec![], "base", "DGX").unwrap();
obj.store
.add_triple(
&sample_triple("a-only", "A"),
Namespace::World,
YLayer::Semantic,
)
.unwrap();
let ca =
create_commit(&obj, &mut commits, vec![base.commit_id.clone()], "a", "DGX").unwrap();
checkout::checkout(&mut obj, &commits, &base.commit_id).unwrap();
obj.store
.add_triple(
&sample_triple("b-only", "B"),
Namespace::Work,
YLayer::Semantic,
)
.unwrap();
let cb =
create_commit(&obj, &mut commits, vec![base.commit_id.clone()], "b", "DGX").unwrap();
let result = merge_with_strategy(
&mut obj,
&mut commits,
&ca.commit_id,
&cb.commit_id,
"DGX",
&MergeStrategy::Ours,
)
.unwrap();
match result {
MergeResult::Clean(mc) => {
assert_eq!(mc.parent_ids.len(), 2);
assert!(obj.store.len() >= 3);
}
_ => panic!("Non-conflicting merge with any strategy should be Clean"),
}
}
}