forked from rojo-rbx/rojo
When a child instance has a Roblox name that would produce a filesystem name of "init" (case-insensitive), syncback now automatically prefixes it with '_' (e.g. "Init" → "_Init.luau") instead of erroring. The corresponding meta.json writes the original name via the `name` property so Rojo can restore it on the next snapshot. The sibling dedup check is updated to use actual on-disk names for existing children and the resolved (init-prefixed) name for new ones, so genuine collisions still error while false positives from the `name` property are avoided. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
525 lines
18 KiB
Rust
525 lines
18 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
path::Path,
|
|
};
|
|
|
|
use anyhow::Context;
|
|
use memofs::{DirEntry, Vfs};
|
|
|
|
use crate::{
|
|
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource},
|
|
syncback::{hash_instance, slugify_name, FsSnapshot, SyncbackReturn, SyncbackSnapshot},
|
|
};
|
|
|
|
use super::{meta_file::DirectoryMetadata, snapshot_from_vfs};
|
|
|
|
const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep";
|
|
|
|
pub fn snapshot_dir(
|
|
context: &InstanceContext,
|
|
vfs: &Vfs,
|
|
path: &Path,
|
|
name: &str,
|
|
) -> anyhow::Result<Option<InstanceSnapshot>> {
|
|
let mut snapshot = match snapshot_dir_no_meta(context, vfs, path, name)? {
|
|
Some(snapshot) => snapshot,
|
|
None => return Ok(None),
|
|
};
|
|
|
|
DirectoryMetadata::read_and_apply_all(vfs, path, &mut snapshot)?;
|
|
|
|
Ok(Some(snapshot))
|
|
}
|
|
|
|
/// Snapshot a directory without applying meta files; useful for if the
|
|
/// directory's ClassName will change before metadata should be applied. For
|
|
/// example, this can happen if the directory contains an `init.client.lua`
|
|
/// file.
|
|
pub fn snapshot_dir_no_meta(
|
|
context: &InstanceContext,
|
|
vfs: &Vfs,
|
|
path: &Path,
|
|
name: &str,
|
|
) -> anyhow::Result<Option<InstanceSnapshot>> {
|
|
let passes_filter_rules = |child: &DirEntry| {
|
|
context
|
|
.path_ignore_rules
|
|
.iter()
|
|
.all(|rule| rule.passes(child.path()))
|
|
};
|
|
|
|
let mut snapshot_children = Vec::new();
|
|
|
|
for entry in vfs.read_dir(path)? {
|
|
let entry = entry?;
|
|
|
|
if !passes_filter_rules(&entry) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(child_snapshot) = snapshot_from_vfs(context, vfs, entry.path())? {
|
|
snapshot_children.push(child_snapshot);
|
|
}
|
|
}
|
|
|
|
let normalized_path = vfs.canonicalize(path)?;
|
|
let relevant_paths = vec![
|
|
normalized_path.clone(),
|
|
// TODO: We shouldn't need to know about Lua existing in this
|
|
// middleware. Should we figure out a way for that function to add
|
|
// relevant paths to this middleware?
|
|
normalized_path.join("init.lua"),
|
|
normalized_path.join("init.luau"),
|
|
normalized_path.join("init.server.lua"),
|
|
normalized_path.join("init.server.luau"),
|
|
normalized_path.join("init.client.lua"),
|
|
normalized_path.join("init.client.luau"),
|
|
normalized_path.join("init.csv"),
|
|
];
|
|
|
|
let snapshot = InstanceSnapshot::new()
|
|
.name(name)
|
|
.class_name("Folder")
|
|
.children(snapshot_children)
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(path)
|
|
.relevant_paths(relevant_paths)
|
|
.context(context),
|
|
);
|
|
|
|
Ok(Some(snapshot))
|
|
}
|
|
|
|
pub fn syncback_dir<'sync>(
|
|
snapshot: &SyncbackSnapshot<'sync>,
|
|
) -> anyhow::Result<SyncbackReturn<'sync>> {
|
|
let new_inst = snapshot.new_inst();
|
|
|
|
let mut dir_syncback = syncback_dir_no_meta(snapshot)?;
|
|
|
|
let mut meta = DirectoryMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?;
|
|
if let Some(meta) = &mut meta {
|
|
if new_inst.class != "Folder" {
|
|
meta.class_name = Some(new_inst.class);
|
|
}
|
|
|
|
if !meta.is_empty() {
|
|
dir_syncback.fs_snapshot.add_file(
|
|
snapshot.path.join("init.meta.json"),
|
|
serde_json::to_vec_pretty(&meta)
|
|
.context("could not serialize new init.meta.json")?,
|
|
);
|
|
}
|
|
}
|
|
|
|
let metadata_empty = meta
|
|
.as_ref()
|
|
.map(DirectoryMetadata::is_empty)
|
|
.unwrap_or_default();
|
|
if new_inst.children().is_empty() && metadata_empty {
|
|
dir_syncback
|
|
.fs_snapshot
|
|
.add_file(snapshot.path.join(EMPTY_DIR_KEEP_NAME), Vec::new())
|
|
}
|
|
|
|
Ok(dir_syncback)
|
|
}
|
|
|
|
pub fn syncback_dir_no_meta<'sync>(
|
|
snapshot: &SyncbackSnapshot<'sync>,
|
|
) -> anyhow::Result<SyncbackReturn<'sync>> {
|
|
let new_inst = snapshot.new_inst();
|
|
|
|
let mut children = Vec::new();
|
|
let mut removed_children = Vec::new();
|
|
|
|
// Build the old child map early so it can be used for deduplication below.
|
|
let mut old_child_map = HashMap::new();
|
|
if let Some(old_inst) = snapshot.old_inst() {
|
|
for child in old_inst.children() {
|
|
let inst = snapshot.get_old_instance(*child).unwrap();
|
|
old_child_map.insert(inst.name(), inst);
|
|
}
|
|
}
|
|
|
|
// Enforce unique filesystem names. Uses actual on-disk names for existing
|
|
// children and resolved names (with init-prefix) for new ones.
|
|
let mut fs_child_names = HashSet::with_capacity(new_inst.children().len());
|
|
let mut duplicate_set = HashSet::new();
|
|
for child_ref in new_inst.children() {
|
|
let child = snapshot.get_new_instance(*child_ref).unwrap();
|
|
let fs_name = old_child_map
|
|
.get(child.name.as_str())
|
|
.and_then(|old| old.metadata().relevant_paths.first())
|
|
.and_then(|p| p.file_name())
|
|
.and_then(|n| n.to_str())
|
|
.map(|s| s.to_lowercase())
|
|
.unwrap_or_else(|| {
|
|
let slug = slugify_name(&child.name);
|
|
let slug_lower = slug.to_lowercase();
|
|
// Mirror name_for_inst's init-prefix.
|
|
if slug_lower == "init" {
|
|
format!("_{slug_lower}")
|
|
} else {
|
|
slug_lower
|
|
}
|
|
});
|
|
|
|
if !fs_child_names.insert(fs_name) {
|
|
duplicate_set.insert(child.name.as_str());
|
|
}
|
|
}
|
|
if !duplicate_set.is_empty() {
|
|
if duplicate_set.len() <= 25 {
|
|
anyhow::bail!(
|
|
"Instance has children with duplicate name (case may not exactly match):\n {}",
|
|
duplicate_set.into_iter().collect::<Vec<&str>>().join(", ")
|
|
);
|
|
}
|
|
anyhow::bail!("Instance has more than 25 children with duplicate names");
|
|
}
|
|
|
|
if snapshot.old_inst().is_some() {
|
|
for new_child_ref in new_inst.children() {
|
|
let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
|
|
if let Some(old_child) = old_child_map.remove(new_child.name.as_str()) {
|
|
if old_child.metadata().relevant_paths.is_empty() {
|
|
log::debug!(
|
|
"Skipping instance {} because it doesn't exist on the disk",
|
|
old_child.name()
|
|
);
|
|
continue;
|
|
} else if matches!(
|
|
old_child.metadata().instigating_source,
|
|
Some(InstigatingSource::ProjectNode { .. })
|
|
) {
|
|
log::debug!(
|
|
"Skipping instance {} because it originates in a project file",
|
|
old_child.name()
|
|
);
|
|
continue;
|
|
}
|
|
// This child exists in both doms. Pass it on.
|
|
children.push(snapshot.with_joined_path(*new_child_ref, Some(old_child.id()))?);
|
|
} else {
|
|
// The child only exists in the the new dom
|
|
children.push(snapshot.with_joined_path(*new_child_ref, None)?);
|
|
}
|
|
}
|
|
// Any children that are in the old dom but not the new one are removed.
|
|
removed_children.extend(old_child_map.into_values());
|
|
} else {
|
|
// There is no old instance. Just add every child.
|
|
for new_child_ref in new_inst.children() {
|
|
children.push(snapshot.with_joined_path(*new_child_ref, None)?);
|
|
}
|
|
}
|
|
let mut fs_snapshot = FsSnapshot::new();
|
|
|
|
if let Some(old_ref) = snapshot.old {
|
|
let new_hash = hash_instance(snapshot.project(), snapshot.new_tree(), snapshot.new)
|
|
.expect("new Instance should be hashable");
|
|
let old_hash = hash_instance(snapshot.project(), snapshot.old_tree(), old_ref)
|
|
.expect("old Instance should be hashable");
|
|
|
|
if old_hash != new_hash {
|
|
fs_snapshot.add_dir(&snapshot.path);
|
|
} else {
|
|
log::debug!(
|
|
"Skipping reserializing directory {} because old and new tree hash the same",
|
|
new_inst.name
|
|
);
|
|
}
|
|
} else {
|
|
fs_snapshot.add_dir(&snapshot.path);
|
|
}
|
|
|
|
Ok(SyncbackReturn {
|
|
fs_snapshot,
|
|
children,
|
|
removed_children,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use crate::{
|
|
snapshot::{InstanceMetadata, InstanceSnapshot},
|
|
Project, RojoTree, SyncbackData, SyncbackSnapshot,
|
|
};
|
|
use memofs::{InMemoryFs, VfsSnapshot};
|
|
|
|
#[test]
|
|
fn empty_folder() {
|
|
let mut imfs = InMemoryFs::new();
|
|
imfs.load_snapshot("/foo", VfsSnapshot::empty_dir())
|
|
.unwrap();
|
|
|
|
let vfs = Vfs::new(imfs);
|
|
|
|
let instance_snapshot =
|
|
snapshot_dir(&InstanceContext::default(), &vfs, Path::new("/foo"), "foo")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
insta::assert_yaml_snapshot!(instance_snapshot);
|
|
}
|
|
|
|
#[test]
|
|
fn folder_in_folder() {
|
|
let mut imfs = InMemoryFs::new();
|
|
imfs.load_snapshot(
|
|
"/foo",
|
|
VfsSnapshot::dir([("Child", VfsSnapshot::empty_dir())]),
|
|
)
|
|
.unwrap();
|
|
|
|
let vfs = Vfs::new(imfs);
|
|
|
|
let instance_snapshot =
|
|
snapshot_dir(&InstanceContext::default(), &vfs, Path::new("/foo"), "foo")
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
insta::assert_yaml_snapshot!(instance_snapshot);
|
|
}
|
|
|
|
fn make_project() -> Project {
|
|
serde_json::from_str(r#"{"tree": {"$className": "DataModel"}}"#).unwrap()
|
|
}
|
|
|
|
fn make_vfs() -> Vfs {
|
|
let mut imfs = InMemoryFs::new();
|
|
imfs.load_snapshot("/root", VfsSnapshot::empty_dir()).unwrap();
|
|
Vfs::new(imfs)
|
|
}
|
|
|
|
/// Two children whose Roblox names are identical when lowercased ("Alpha"
|
|
/// and "alpha") but live at different filesystem paths because of the
|
|
/// `name` property ("Beta/" and "Alpha/" respectively). The dedup check
|
|
/// must use the actual filesystem paths, not the raw Roblox names, to
|
|
/// avoid a false-positive duplicate error.
|
|
#[test]
|
|
fn syncback_no_false_duplicate_with_name_prop() {
|
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
|
|
|
// Old child A: Roblox name "Alpha", on disk at "/root/Beta"
|
|
// (name property maps "Alpha" → "Beta" on the filesystem)
|
|
let old_child_a = InstanceSnapshot::new()
|
|
.name("Alpha")
|
|
.class_name("Folder")
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(PathBuf::from("/root/Beta"))
|
|
.relevant_paths(vec![PathBuf::from("/root/Beta")]),
|
|
);
|
|
// Old child B: Roblox name "alpha", on disk at "/root/Alpha"
|
|
let old_child_b = InstanceSnapshot::new()
|
|
.name("alpha")
|
|
.class_name("Folder")
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(PathBuf::from("/root/Alpha"))
|
|
.relevant_paths(vec![PathBuf::from("/root/Alpha")]),
|
|
);
|
|
let old_parent = InstanceSnapshot::new()
|
|
.name("Parent")
|
|
.class_name("Folder")
|
|
.children(vec![old_child_a, old_child_b])
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(PathBuf::from("/root"))
|
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
|
);
|
|
let old_tree = RojoTree::new(old_parent);
|
|
|
|
// New state: same two children in Roblox.
|
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
|
let new_parent = new_tree.insert(
|
|
new_tree.root_ref(),
|
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
|
);
|
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Alpha"));
|
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("alpha"));
|
|
|
|
let vfs = make_vfs();
|
|
let project = make_project();
|
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
|
let snapshot = SyncbackSnapshot {
|
|
data,
|
|
old: Some(old_tree.get_root_id()),
|
|
new: new_parent,
|
|
path: PathBuf::from("/root"),
|
|
middleware: None,
|
|
};
|
|
|
|
let result = syncback_dir_no_meta(&snapshot);
|
|
assert!(
|
|
result.is_ok(),
|
|
"should not error when two children have the same lowercased Roblox \
|
|
name but map to distinct filesystem paths: {result:?}",
|
|
);
|
|
}
|
|
|
|
/// Two completely new children with the same non-init name would produce
|
|
/// the same filesystem entry and must be detected as a duplicate.
|
|
#[test]
|
|
fn syncback_detects_sibling_duplicate_names() {
|
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
|
|
|
let old_parent = InstanceSnapshot::new()
|
|
.name("Parent")
|
|
.class_name("Folder")
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(PathBuf::from("/root"))
|
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
|
);
|
|
let old_tree = RojoTree::new(old_parent);
|
|
|
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
|
let new_parent = new_tree.insert(
|
|
new_tree.root_ref(),
|
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
|
);
|
|
// "Foo" is not a reserved name but two siblings named "Foo" still
|
|
// collide on disk.
|
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));
|
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));
|
|
|
|
let vfs = make_vfs();
|
|
let project = make_project();
|
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
|
let snapshot = SyncbackSnapshot {
|
|
data,
|
|
old: Some(old_tree.get_root_id()),
|
|
new: new_parent,
|
|
path: PathBuf::from("/root"),
|
|
middleware: None,
|
|
};
|
|
|
|
let result = syncback_dir_no_meta(&snapshot);
|
|
assert!(
|
|
result.is_err(),
|
|
"should error when two new children would produce the same filesystem name",
|
|
);
|
|
}
|
|
|
|
/// A new child named "Init" (as a ModuleScript) would naively become
|
|
/// "Init.luau", which case-insensitively matches the parent's reserved
|
|
/// "init.luau". Syncback must resolve this automatically by prefixing the
|
|
/// filesystem name with '_' (→ "_Init.luau") rather than erroring.
|
|
#[test]
|
|
fn syncback_resolves_init_name_conflict() {
|
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
|
|
|
let old_parent = InstanceSnapshot::new()
|
|
.name("Parent")
|
|
.class_name("Folder")
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(PathBuf::from("/root"))
|
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
|
);
|
|
let old_tree = RojoTree::new(old_parent);
|
|
|
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
|
let new_parent = new_tree.insert(
|
|
new_tree.root_ref(),
|
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
|
);
|
|
new_tree.insert(
|
|
new_parent,
|
|
InstanceBuilder::new("ModuleScript").with_name("Init"),
|
|
);
|
|
|
|
let vfs = make_vfs();
|
|
let project = make_project();
|
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
|
let snapshot = SyncbackSnapshot {
|
|
data,
|
|
old: Some(old_tree.get_root_id()),
|
|
new: new_parent,
|
|
path: PathBuf::from("/root"),
|
|
middleware: None,
|
|
};
|
|
|
|
let result = syncback_dir_no_meta(&snapshot);
|
|
assert!(
|
|
result.is_ok(),
|
|
"should resolve init-name conflict by prefixing '_', not error: {result:?}",
|
|
);
|
|
// The child should have been placed at "_Init.luau", not "Init.luau".
|
|
let child_file_name = result
|
|
.unwrap()
|
|
.children
|
|
.into_iter()
|
|
.next()
|
|
.and_then(|c| c.path.file_name().map(|n| n.to_string_lossy().into_owned()))
|
|
.unwrap_or_default();
|
|
assert!(
|
|
child_file_name.starts_with('_'),
|
|
"child filesystem name should start with '_' to avoid init collision, \
|
|
got: {child_file_name}",
|
|
);
|
|
}
|
|
|
|
/// A child whose filesystem name is stored with a slugified prefix (e.g.
|
|
/// "_Init") must NOT be blocked — only the bare "init" stem is reserved.
|
|
#[test]
|
|
fn syncback_allows_slugified_init_name() {
|
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
|
|
|
// Existing child: on disk as "_Init" (slugified from a name with an
|
|
// illegal character), its stem is "_init" which is not reserved.
|
|
let old_child = InstanceSnapshot::new()
|
|
.name("Init")
|
|
.class_name("Folder")
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(PathBuf::from("/root/_Init"))
|
|
.relevant_paths(vec![PathBuf::from("/root/_Init")]),
|
|
);
|
|
let old_parent = InstanceSnapshot::new()
|
|
.name("Parent")
|
|
.class_name("Folder")
|
|
.children(vec![old_child])
|
|
.metadata(
|
|
InstanceMetadata::new()
|
|
.instigating_source(PathBuf::from("/root"))
|
|
.relevant_paths(vec![PathBuf::from("/root")]),
|
|
);
|
|
let old_tree = RojoTree::new(old_parent);
|
|
|
|
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
|
let new_parent = new_tree.insert(
|
|
new_tree.root_ref(),
|
|
InstanceBuilder::new("Folder").with_name("Parent"),
|
|
);
|
|
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Init"));
|
|
|
|
let vfs = make_vfs();
|
|
let project = make_project();
|
|
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
|
|
let snapshot = SyncbackSnapshot {
|
|
data,
|
|
old: Some(old_tree.get_root_id()),
|
|
new: new_parent,
|
|
path: PathBuf::from("/root"),
|
|
middleware: None,
|
|
};
|
|
|
|
let result = syncback_dir_no_meta(&snapshot);
|
|
assert!(
|
|
result.is_ok(),
|
|
"should allow a child whose filesystem name is slugified away from \
|
|
the reserved 'init' stem: {result:?}",
|
|
);
|
|
}
|
|
}
|