Record file root sync conflicts

This commit is contained in:
Eric Wendland 2026-05-21 01:40:50 +02:00
commit 475c6a4a2a
8 changed files with 324 additions and 14 deletions

View file

@ -136,6 +136,13 @@ pub struct FileConflict {
pub resolved_at_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeConflict {
pub path: String,
pub kind: FileConflictKind,
pub detail: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileConflictKind {
@ -540,6 +547,74 @@ pub fn diff_tree_objects(
changes
}
pub fn detect_tree_conflicts(
base: &CasTreeObject,
local: &CasTreeObject,
remote: &CasTreeObject,
) -> Vec<TreeConflict> {
let base_entries = tree_entry_map(base);
let local_entries = tree_entry_map(local);
let remote_entries = tree_entry_map(remote);
let mut paths = BTreeSet::new();
paths.extend(base_entries.keys().cloned());
paths.extend(local_entries.keys().cloned());
paths.extend(remote_entries.keys().cloned());
let local_renames = rename_sources(base, local);
let remote_renames = rename_sources(base, remote);
let mut conflicts = Vec::new();
for (from, local_to) in &local_renames {
if let Some(remote_to) = remote_renames.get(from) {
if local_to == remote_to {
continue;
}
conflicts.push(TreeConflict {
path: from.clone(),
kind: FileConflictKind::Rename,
detail: format!(
"local renamed {from} to {local_to}, while remote renamed it to {remote_to}"
),
});
}
}
for path in paths {
let base_entry = base_entries.get(&path).copied();
let local_entry = local_entries.get(&path).copied();
let remote_entry = remote_entries.get(&path).copied();
let local_changed = base_entry != local_entry;
let remote_changed = base_entry != remote_entry;
if !local_changed || !remote_changed || local_entry == remote_entry {
continue;
}
if conflicts.iter().any(|conflict| conflict.path == path) {
continue;
}
let kind = if base_entry.is_some() && (local_entry.is_none() || remote_entry.is_none()) {
FileConflictKind::DeleteEdit
} else {
FileConflictKind::ConcurrentEdit
};
let detail = match kind {
FileConflictKind::ConcurrentEdit => {
format!("local and remote both changed {path} from the previous imported base")
}
FileConflictKind::DeleteEdit => {
format!("one side deleted {path} while the other side changed it")
}
FileConflictKind::Rename => unreachable!("rename conflicts are detected above"),
};
conflicts.push(TreeConflict { path, kind, detail });
}
conflicts.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| left.kind.as_str().cmp(right.kind.as_str()))
});
conflicts
}
#[must_use]
pub fn file_root_scan_note() -> &'static str {
"local scan only; geth never overwrites file roots without a recorded future sync decision"
@ -561,6 +636,18 @@ fn change_sort_key(change: &FileRootChange) -> (u8, String, String) {
}
}
fn rename_sources(base: &CasTreeObject, current: &CasTreeObject) -> BTreeMap<String, String> {
diff_tree_objects(Some(base), current)
.into_iter()
.filter_map(|change| match change {
FileRootChange::Renamed { from, to } => Some((from, to)),
FileRootChange::Created { .. }
| FileRootChange::Modified { .. }
| FileRootChange::Deleted { .. } => None,
})
.collect()
}
fn collect_tree_entries(
cas: &LocalCas,
root: &Path,
@ -816,6 +903,45 @@ mod tests {
assert_eq!(delete_count, 1);
}
#[test]
fn tree_conflicts_detect_concurrent_edit_delete_edit_and_rename() {
let base = CasTreeObject {
version: CAS_TREE_OBJECT_VERSION,
entries: vec![
tree_file("edit.txt", "01", 1),
tree_file("delete-edit.txt", "02", 1),
tree_file("rename.txt", "03", 1),
],
};
let local = CasTreeObject {
version: CAS_TREE_OBJECT_VERSION,
entries: vec![
tree_file("edit.txt", "04", 1),
tree_file("delete-edit.txt", "05", 1),
tree_file("local-rename.txt", "03", 1),
],
};
let remote = CasTreeObject {
version: CAS_TREE_OBJECT_VERSION,
entries: vec![
tree_file("edit.txt", "06", 1),
tree_file("remote-rename.txt", "03", 1),
],
};
let conflicts = detect_tree_conflicts(&base, &local, &remote);
assert!(conflicts.iter().any(|conflict| {
conflict.path == "edit.txt" && conflict.kind == FileConflictKind::ConcurrentEdit
}));
assert!(conflicts.iter().any(|conflict| {
conflict.path == "delete-edit.txt" && conflict.kind == FileConflictKind::DeleteEdit
}));
assert!(conflicts.iter().any(|conflict| {
conflict.path == "rename.txt" && conflict.kind == FileConflictKind::Rename
}));
}
fn tree_file(path: &str, byte: &str, size_bytes: u64) -> CasTreeEntry {
CasTreeEntry {
path: path.to_owned(),