Implement fix for stem-level deduplication in file handling

- Introduced a migration plan to address the deduplication issue where bare slugs were compared against full filenames, leading to undetected file-format collisions.
- Updated `name_for_inst` to return a deduplication key alongside the filename and metadata flag, ensuring callers use the bare slug for collision detection.
- Replaced disk-based seeding of `taken_names` with tree-based slugified names to maintain consistency and avoid extension mismatches.
- Refactored relevant modules including `dir.rs`, `project.rs`, and `api.rs` to utilize the new deduplication logic.
- Updated tests to validate the new deduplication behavior and ensure correct handling of file-format instances.
This commit is contained in:
Joe 2026-02-10 03:32:17 -06:00
parent 126c8e66a4
commit 560fce1352
9 changed files with 333 additions and 208 deletions

View file

@ -138,24 +138,12 @@ pub fn syncback_dir_no_meta<'sync>(
let mut children = Vec::new();
let mut removed_children = Vec::new();
// Seed taken_names with existing files on disk so new children don't
// collide with files already present in the directory.
let mut taken_names: HashSet<String> = snapshot
.vfs()
.read_dir(&snapshot.path)
.ok()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter_map(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_lowercase())
})
.collect()
})
.unwrap_or_default();
// taken_names tracks claimed bare slugs (without extensions) for dedup.
// It is NOT seeded from disk filenames — those contain extensions that
// would cause mismatches with bare-slug comparisons. Instead, old
// children's dedup_keys are accumulated as they are processed in the
// loop below (via name_for_inst's third return value).
let mut taken_names: HashSet<String> = HashSet::new();
// Detect duplicate child names (case-insensitive for file system safety).
// We skip duplicates instead of failing, tracking them in stats.
@ -239,22 +227,18 @@ pub fn syncback_dir_no_meta<'sync>(
continue;
}
// This child exists in both doms. Pass it on.
let (child_snap, _needs_meta) = snapshot.with_joined_path(
let (child_snap, _needs_meta, dedup_key) = snapshot.with_joined_path(
*new_child_ref,
Some(old_child.id()),
&taken_names,
)?;
if let Some(name) = child_snap.path.file_name().and_then(|n| n.to_str()) {
taken_names.insert(name.to_lowercase());
}
taken_names.insert(dedup_key.to_lowercase());
children.push(child_snap);
} else {
// The child only exists in the the new dom
let (child_snap, _needs_meta) =
let (child_snap, _needs_meta, dedup_key) =
snapshot.with_joined_path(*new_child_ref, None, &taken_names)?;
if let Some(name) = child_snap.path.file_name().and_then(|n| n.to_str()) {
taken_names.insert(name.to_lowercase());
}
taken_names.insert(dedup_key.to_lowercase());
children.push(child_snap);
}
}
@ -299,11 +283,9 @@ pub fn syncback_dir_no_meta<'sync>(
if snapshot.should_ignore_tree(*new_child_ref) {
continue;
}
let (child_snap, _needs_meta) =
let (child_snap, _needs_meta, dedup_key) =
snapshot.with_joined_path(*new_child_ref, None, &taken_names)?;
if let Some(name) = child_snap.path.file_name().and_then(|n| n.to_str()) {
taken_names.insert(name.to_lowercase());
}
taken_names.insert(dedup_key.to_lowercase());
children.push(child_snap);
}
}

View file

@ -77,6 +77,11 @@ pub fn syncback_json_model<'sync>(
// could have a performance cost.
model.schema = old_inst.metadata().schema.clone();
model.name = old_inst.metadata().specified_name.clone();
} else if snapshot.needs_meta_name {
// New instance whose filesystem name differs from the instance name
// (due to slugification or deduplication). Store the real name so it
// survives a round-trip through the filesystem.
model.name = Some(snapshot.new_inst().name.clone());
}
let serialized = match crate::json::to_vec_pretty_sorted(&model) {

View file

@ -13,7 +13,7 @@ use crate::{
json,
resolution::UnresolvedValue,
snapshot::InstanceSnapshot,
syncback::{name_needs_slugify, SyncbackSnapshot},
syncback::SyncbackSnapshot,
RojoRef,
};
@ -165,15 +165,11 @@ impl AdjacentMetadata {
.old_inst()
.and_then(|inst| inst.metadata().specified_name.clone())
.or_else(|| {
// If this is a new instance and its name is invalid for the filesystem,
// we need to specify the name in meta.json so it can be preserved
if snapshot.old_inst().is_none() {
let instance_name = &snapshot.new_inst().name;
if name_needs_slugify(instance_name) {
Some(instance_name.clone())
} else {
None
}
// If this is a new instance and its filesystem name differs from
// the instance name (due to slugification or deduplication), we
// need to specify the name in meta.json so it can be preserved.
if snapshot.old_inst().is_none() && snapshot.needs_meta_name {
Some(snapshot.new_inst().name.clone())
} else {
None
}
@ -431,15 +427,11 @@ impl DirectoryMetadata {
.old_inst()
.and_then(|inst| inst.metadata().specified_name.clone())
.or_else(|| {
// If this is a new instance and its name is invalid for the filesystem,
// we need to specify the name in meta.json so it can be preserved
if snapshot.old_inst().is_none() {
let instance_name = &snapshot.new_inst().name;
if name_needs_slugify(instance_name) {
Some(instance_name.clone())
} else {
None
}
// If this is a new instance and its filesystem name differs from
// the instance name (due to slugification or deduplication), we
// need to specify the name in meta.json so it can be preserved.
if snapshot.old_inst().is_none() && snapshot.needs_meta_name {
Some(snapshot.new_inst().name.clone())
} else {
None
}

View file

@ -648,8 +648,8 @@ pub fn syncback_project<'sync>(
// All of the children in this loop are by their nature not in the
// project, so we just need to run syncback on them.
//
// Track taken names per parent directory so siblings under the same
// parent deduplicate against each other (and against existing files).
// Track taken names (bare slugs) per parent directory so siblings
// under the same parent deduplicate against each other.
let mut taken_names_per_dir: std::collections::HashMap<
std::path::PathBuf,
std::collections::HashSet<String>,
@ -690,35 +690,14 @@ pub fn syncback_project<'sync>(
if parent_middleware != Middleware::Project {
let taken_names = taken_names_per_dir
.entry(parent_path.clone())
.or_insert_with(|| {
// Seed with existing files on disk so new children
// don't collide with files already present.
vfs.read_dir(&parent_path)
.ok()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter_map(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_lowercase())
})
.collect()
})
.unwrap_or_default()
});
let (child_snap, _needs_meta) = snapshot.with_base_path(
.or_insert_with(std::collections::HashSet::new);
let (child_snap, _needs_meta, dedup_key) = snapshot.with_base_path(
&parent_path,
new_child.referent(),
None,
taken_names,
)?;
if let Some(file_name) =
child_snap.path.file_name().and_then(|n| n.to_str())
{
taken_names.insert(file_name.to_lowercase());
}
taken_names.insert(dedup_key.to_lowercase());
descendant_snapshots.push(child_snap);
}
}