Record file root sync conflicts
This commit is contained in:
parent
533ffc8c2a
commit
475c6a4a2a
8 changed files with 324 additions and 14 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue