Skip to content

Commit cf2aa07

Browse files
fix(merge): conflict on delete modify collisions (#215)
1 parent 894fedb commit cf2aa07

3 files changed

Lines changed: 102 additions & 12 deletions

File tree

src/git/versioned_store/backends.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,36 @@ impl<const N: usize> VersionedKvStore<N, GitNodeStorage<N>, GitMetadataBackend>
234234
}
235235
}
236236
// Key deleted in source, still exists in dest
237-
(Some(_base), None, Some(_dest)) => {
238-
// Source deleted it - apply deletion
239-
merge_results.push(crate::diff::MergeResult::Removed(key));
237+
(Some(base), None, Some(dest)) => {
238+
if base == dest {
239+
// Destination unchanged from base - safe to apply source deletion
240+
merge_results.push(crate::diff::MergeResult::Removed(key));
241+
} else {
242+
// Destination modified while source deleted - conflict
243+
let conflict = crate::diff::MergeConflict {
244+
key: key.clone(),
245+
base_value: Some(base.clone()),
246+
source_value: None,
247+
destination_value: Some(dest.clone()),
248+
};
249+
merge_results.push(crate::diff::MergeResult::Conflict(conflict));
250+
}
240251
}
241252
// Key deleted in dest, still exists in source - keep deletion (no-op)
242-
(Some(_base), Some(_source), None) => {
243-
continue;
253+
(Some(base), Some(source), None) => {
254+
if base == source {
255+
// Source unchanged from base - keep destination deletion
256+
continue;
257+
} else {
258+
// Source modified while destination deleted - conflict
259+
let conflict = crate::diff::MergeConflict {
260+
key: key.clone(),
261+
base_value: Some(base.clone()),
262+
source_value: Some(source.clone()),
263+
destination_value: None,
264+
};
265+
merge_results.push(crate::diff::MergeResult::Conflict(conflict));
266+
}
244267
}
245268
// Key deleted in both - no-op
246269
(Some(_base), None, None) => {

src/git/versioned_store/history.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -506,13 +506,36 @@ where
506506
}
507507
}
508508
// Key deleted in source, still exists in dest
509-
(Some(_base), None, Some(_dest)) => {
510-
// Source deleted it - apply deletion
511-
merge_results.push(crate::diff::MergeResult::Removed(key.clone()));
509+
(Some(base), None, Some(dest)) => {
510+
if base == dest {
511+
// Destination unchanged from base - safe to apply source deletion
512+
merge_results.push(crate::diff::MergeResult::Removed(key.clone()));
513+
} else {
514+
// Destination modified while source deleted - conflict
515+
let conflict = crate::diff::MergeConflict {
516+
key: key.clone(),
517+
base_value: Some(base.clone()),
518+
source_value: None,
519+
destination_value: Some(dest.clone()),
520+
};
521+
merge_results.push(crate::diff::MergeResult::Conflict(conflict));
522+
}
512523
}
513524
// Key deleted in dest, still exists in source - keep deletion (no-op)
514-
(Some(_base), Some(_source), None) => {
515-
continue;
525+
(Some(base), Some(source), None) => {
526+
if base == source {
527+
// Source unchanged from base - keep destination deletion
528+
continue;
529+
} else {
530+
// Source modified while destination deleted - conflict
531+
let conflict = crate::diff::MergeConflict {
532+
key: key.clone(),
533+
base_value: Some(base.clone()),
534+
source_value: Some(source.clone()),
535+
destination_value: None,
536+
};
537+
merge_results.push(crate::diff::MergeResult::Conflict(conflict));
538+
}
516539
}
517540
// Key deleted in both - no-op
518541
(Some(_base), None, None) => {

tests/conflict_resolvers.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,19 @@ limitations under the License.
1818

1919
mod common;
2020

21-
use prollytree::diff::{IgnoreConflictsResolver, TakeDestinationResolver, TakeSourceResolver};
22-
use prollytree::git::versioned_store::GitVersionedKvStore;
21+
use prollytree::diff::{
22+
ConflictResolver, IgnoreConflictsResolver, MergeConflict, MergeResult, TakeDestinationResolver,
23+
TakeSourceResolver,
24+
};
25+
use prollytree::git::versioned_store::{GitVersionedKvStore, InMemoryVersionedKvStore};
26+
27+
struct LeaveUnresolved;
28+
29+
impl ConflictResolver for LeaveUnresolved {
30+
fn resolve_conflict(&self, _conflict: &MergeConflict) -> Option<MergeResult> {
31+
None
32+
}
33+
}
2334

2435
// ---------------------------------------------------------------------------
2536
// Helper: set up divergent branches with a conflict on a shared key
@@ -232,3 +243,36 @@ fn test_mixed_adds_deletes_conflicts() {
232243

233244
std::mem::forget(_temp);
234245
}
246+
247+
#[test]
248+
fn test_delete_modify_merge_surfaces_conflict_and_preserves_destination() {
249+
let (_temp, dataset) = common::setup_repo_and_dataset();
250+
let mut store = InMemoryVersionedKvStore::<32>::init(&dataset).expect("init");
251+
252+
store.insert(b"K".to_vec(), b"v0".to_vec()).unwrap();
253+
store.commit("base").unwrap();
254+
store.create_branch("feature").unwrap();
255+
store.checkout_generic("main").unwrap();
256+
257+
store.insert(b"K".to_vec(), b"v1".to_vec()).unwrap();
258+
store.commit("main modifies K").unwrap();
259+
260+
store.checkout_generic("feature").unwrap();
261+
assert!(store.delete(b"K").unwrap());
262+
store.commit("feature deletes K").unwrap();
263+
264+
store.checkout_generic("main").unwrap();
265+
let result = store.merge_generic("feature", &LeaveUnresolved);
266+
267+
assert!(
268+
result.is_err(),
269+
"delete/modify merge must surface an unresolved conflict"
270+
);
271+
assert_eq!(
272+
store.get(b"K"),
273+
Some(b"v1".to_vec()),
274+
"destination modification must not be silently deleted"
275+
);
276+
277+
std::mem::forget(_temp);
278+
}

0 commit comments

Comments
 (0)