mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Implement fixes and enhancements from audit pass 2
- Introduced a new plan addressing critical bugs, correctness issues, and refactors identified during the audit, including improvements to metadata handling and slugification logic. - Replaced inline removal logic in `handle_api_write` with a new `remove_instance_at_path` method to ensure correct deletion of instances. - Updated pre-seeding logic in `dir.rs` and `project.rs` to derive deduplication keys from actual filesystem paths instead of slugified names. - Enhanced rename handling in `change_processor.rs` to prevent collisions by deduplicating names against existing siblings. - Added tests for various scenarios, including handling of forbidden characters and ensuring correct deduplication behavior. - Refactored child iteration in `dir.rs` and `project.rs` to sort children alphabetically for deterministic deduplication ordering.
This commit is contained in:
parent
baf05dcb3d
commit
2ff5c3936c
30 changed files with 871 additions and 229 deletions
|
|
@ -15,7 +15,7 @@ use crate::{
|
|||
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
|
||||
},
|
||||
snapshot_middleware::{snapshot_from_vfs, snapshot_project_node},
|
||||
syncback::{name_needs_slugify, slugify_name},
|
||||
syncback::{deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix},
|
||||
};
|
||||
|
||||
/// Processes file change events, updates the DOM, and sends those updates
|
||||
|
|
@ -226,6 +226,13 @@ impl JobThreadContext {
|
|||
}
|
||||
}
|
||||
|
||||
/// Suppress the next Remove VFS event for the given path.
|
||||
fn suppress_path_remove(&self, path: &Path) {
|
||||
let mut suppressed = self.suppressed_paths.lock().unwrap();
|
||||
let key = Self::suppression_key(path);
|
||||
suppressed.entry(key).or_insert((0, 0)).0 += 1;
|
||||
}
|
||||
|
||||
/// Upsert the `name` field in a `.meta.json5` file, suppressing filesystem
|
||||
/// events to avoid feedback loops.
|
||||
fn upsert_meta_name_field(&self, meta_path: &Path, real_name: &str) {
|
||||
|
|
@ -251,10 +258,11 @@ impl JobThreadContext {
|
|||
self.unsuppress_path(meta_path);
|
||||
}
|
||||
Ok(RemoveNameOutcome::FileDeleted) => {
|
||||
// File was deleted — also suppress the Remove event.
|
||||
// suppress_path already covers Create/Write; we need
|
||||
// suppress_path_any for the Remove event as well.
|
||||
self.suppress_path_any(meta_path);
|
||||
// File was deleted, not rewritten. Swap: undo the
|
||||
// pre-emptive Write suppression and add a Remove
|
||||
// suppression instead so the counts are (1, 0).
|
||||
self.unsuppress_path(meta_path);
|
||||
self.suppress_path_remove(meta_path);
|
||||
}
|
||||
Ok(RemoveNameOutcome::FieldRemoved) => {
|
||||
// File was rewritten — suppress_path already covers it.
|
||||
|
|
@ -606,7 +614,7 @@ impl JobThreadContext {
|
|||
let mut tree = self.tree.lock().unwrap();
|
||||
|
||||
// NOTE: We do NOT delete files from disk here. The API handler
|
||||
// (handle_api_write → syncback_removed_instance) already deleted
|
||||
// (handle_api_write → remove_instance_at_path) already deleted
|
||||
// the files before sending this PatchSet. Re-deleting would
|
||||
// destroy any new file created at the same path — e.g., when an
|
||||
// instance is renamed from "joe_test" to "joe/test", the new slug
|
||||
|
|
@ -688,6 +696,11 @@ impl JobThreadContext {
|
|||
} else {
|
||||
new_name.to_string()
|
||||
};
|
||||
// Directory renames: fs::rename fails safely
|
||||
// on all platforms if the target is a non-empty
|
||||
// directory, so no dedup is needed here (unlike
|
||||
// the file rename path below, where Unix
|
||||
// fs::rename silently overwrites).
|
||||
let new_dir_path =
|
||||
grandparent.join(&slugified_new_name);
|
||||
|
||||
|
|
@ -791,12 +804,57 @@ impl JobThreadContext {
|
|||
} else {
|
||||
new_name.to_string()
|
||||
};
|
||||
|
||||
// Guard against rename collision: if the target
|
||||
// path already exists and isn't our own file,
|
||||
// deduplicate the name against siblings.
|
||||
let deduped_new_name;
|
||||
{
|
||||
let candidate_file = if extension.is_empty() {
|
||||
format!("{}{}", slugified_new_name, script_suffix)
|
||||
} else {
|
||||
format!(
|
||||
"{}{}.{}",
|
||||
slugified_new_name, script_suffix, extension
|
||||
)
|
||||
};
|
||||
let candidate = parent.join(&candidate_file);
|
||||
if candidate != *path && candidate.exists() {
|
||||
let mut taken: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
if let Ok(entries) = fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
let ep = entry.path();
|
||||
let slug = if ep.is_dir() {
|
||||
ep.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
} else {
|
||||
let s = ep
|
||||
.file_stem()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
strip_script_suffix(s).to_lowercase()
|
||||
};
|
||||
taken.insert(slug);
|
||||
}
|
||||
}
|
||||
// Free the slot we're vacating
|
||||
taken.remove(&old_base.to_lowercase());
|
||||
deduped_new_name =
|
||||
deduplicate_name(&slugified_new_name, &taken);
|
||||
} else {
|
||||
deduped_new_name = slugified_new_name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let new_file_name = if extension.is_empty() {
|
||||
format!("{}{}", slugified_new_name, script_suffix)
|
||||
format!("{}{}", deduped_new_name, script_suffix)
|
||||
} else {
|
||||
format!(
|
||||
"{}{}.{}",
|
||||
slugified_new_name, script_suffix, extension
|
||||
deduped_new_name, script_suffix, extension
|
||||
)
|
||||
};
|
||||
let new_path = parent.join(&new_file_name);
|
||||
|
|
@ -828,7 +886,7 @@ impl JobThreadContext {
|
|||
.join(format!("{}.meta.json5", old_base));
|
||||
let new_meta = parent.join(format!(
|
||||
"{}.meta.json5",
|
||||
slugified_new_name
|
||||
deduped_new_name
|
||||
));
|
||||
if old_meta.exists() {
|
||||
self.suppress_path_any(&old_meta);
|
||||
|
|
@ -839,7 +897,7 @@ impl JobThreadContext {
|
|||
self.unsuppress_path(&new_meta);
|
||||
}
|
||||
}
|
||||
effective_meta_base = &slugified_new_name;
|
||||
effective_meta_base = &deduped_new_name;
|
||||
}
|
||||
} else {
|
||||
effective_meta_base = old_base;
|
||||
|
|
@ -848,12 +906,13 @@ impl JobThreadContext {
|
|||
// Always update the meta name field when the
|
||||
// instance name changed, even if the path didn't
|
||||
// change (e.g. "Foo/Bar" → "Foo|Bar" both slugify
|
||||
// to "Foo_Bar").
|
||||
// to "Foo_Bar"). Use deduped_new_name because
|
||||
// dedup may have appended ~N.
|
||||
let current_meta = parent.join(format!(
|
||||
"{}.meta.json5",
|
||||
effective_meta_base
|
||||
));
|
||||
if slugified_new_name != *new_name {
|
||||
if deduped_new_name != *new_name {
|
||||
self.upsert_meta_name_field(
|
||||
¤t_meta,
|
||||
new_name,
|
||||
|
|
|
|||
|
|
@ -77,10 +77,16 @@ pub fn snapshot_csv_init(
|
|||
|
||||
let mut init_snapshot = snapshot_csv(context, vfs, init_path, &dir_snapshot.name)?.unwrap();
|
||||
|
||||
// Preserve the init script's instigating_source (the actual file path)
|
||||
// before copying the directory's metadata (which has the folder path)
|
||||
let script_instigating_source = init_snapshot.metadata.instigating_source.take();
|
||||
|
||||
init_snapshot.children = dir_snapshot.children;
|
||||
init_snapshot.metadata = dir_snapshot.metadata;
|
||||
// The directory snapshot middleware includes all possible init paths
|
||||
// so we don't need to add it here.
|
||||
|
||||
// Restore the init script's instigating_source so two-way sync writes
|
||||
// to the actual file (e.g., init.csv) instead of the directory
|
||||
init_snapshot.metadata.instigating_source = script_instigating_source;
|
||||
|
||||
DirectoryMetadata::read_and_apply_all(vfs, folder_path, &mut init_snapshot)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ use memofs::{DirEntry, Vfs};
|
|||
|
||||
use crate::{
|
||||
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource},
|
||||
snapshot_middleware::Middleware,
|
||||
syncback::{
|
||||
hash_instance, name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn,
|
||||
SyncbackSnapshot,
|
||||
hash_instance, name_needs_slugify, slugify_name, strip_middleware_extension, FsSnapshot,
|
||||
SyncbackReturn, SyncbackSnapshot,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -186,25 +187,49 @@ pub fn syncback_dir_no_meta<'sync>(
|
|||
old_child_map.insert(inst.name(), inst);
|
||||
}
|
||||
|
||||
// Pre-seed taken_names from old children's slugified instance names
|
||||
// so that new-only children correctly dedup against existing siblings
|
||||
// (e.g., new "A/B" → slug "A_B" won't collide with existing "A_B").
|
||||
// Pre-seed taken_names from old children's actual filesystem dedup keys
|
||||
// so that new-only children correctly dedup against existing siblings.
|
||||
// We derive keys from relevant_paths (the real filename on disk) rather
|
||||
// than slugifying instance names, because an old instance may already
|
||||
// have a tilde suffix (e.g. A_B~1.luau from prior dedup) that the bare
|
||||
// slug wouldn't capture.
|
||||
// Only in incremental mode: in clean mode, old_ref is forced to None
|
||||
// for all children, so every child is treated as new and pre-seeding
|
||||
// would cause false collisions (spurious ~1 suffixes).
|
||||
if snapshot.data.is_incremental() {
|
||||
for inst in old_child_map.values() {
|
||||
let name = inst.name();
|
||||
let slug = if name_needs_slugify(name) {
|
||||
slugify_name(name).to_lowercase()
|
||||
if let Some(path) = inst.metadata().relevant_paths.first() {
|
||||
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
|
||||
let middleware = inst.metadata().middleware.unwrap_or(Middleware::Dir);
|
||||
let dedup_key =
|
||||
strip_middleware_extension(filename, middleware).to_lowercase();
|
||||
taken_names.insert(dedup_key);
|
||||
}
|
||||
} else {
|
||||
name.to_lowercase()
|
||||
};
|
||||
taken_names.insert(slug);
|
||||
// Fallback (shouldn't happen for old instances with disk presence)
|
||||
let name = inst.name();
|
||||
let slug = if name_needs_slugify(name) {
|
||||
slugify_name(name).to_lowercase()
|
||||
} else {
|
||||
name.to_lowercase()
|
||||
};
|
||||
taken_names.insert(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for new_child_ref in new_inst.children() {
|
||||
// Sort children alphabetically for deterministic dedup ordering.
|
||||
// Without this, DOM iteration order determines which sibling gets
|
||||
// the base slug vs ~1, causing non-deterministic output across
|
||||
// different serializations of the same place.
|
||||
let mut sorted_children: Vec<_> = new_inst
|
||||
.children()
|
||||
.iter()
|
||||
.map(|r| (*r, snapshot.get_new_instance(*r).unwrap().name.clone()))
|
||||
.collect();
|
||||
sorted_children.sort_by(|(_, a), (_, b)| a.cmp(b));
|
||||
|
||||
for (new_child_ref, _) in &sorted_children {
|
||||
let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
|
||||
|
||||
// Skip children with duplicate names - cannot reliably sync them
|
||||
|
|
@ -282,7 +307,15 @@ pub fn syncback_dir_no_meta<'sync>(
|
|||
}));
|
||||
} else {
|
||||
// There is no old instance. Just add every child.
|
||||
for new_child_ref in new_inst.children() {
|
||||
// Sort alphabetically for deterministic dedup ordering.
|
||||
let mut sorted_children: Vec<_> = new_inst
|
||||
.children()
|
||||
.iter()
|
||||
.map(|r| (*r, snapshot.get_new_instance(*r).unwrap().name.clone()))
|
||||
.collect();
|
||||
sorted_children.sort_by(|(_, a), (_, b)| a.cmp(b));
|
||||
|
||||
for (new_child_ref, _) in &sorted_children {
|
||||
let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
|
||||
|
||||
// Skip children with duplicate names - cannot reliably sync them
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ use crate::{
|
|||
},
|
||||
snapshot_middleware::Middleware,
|
||||
syncback::{
|
||||
filter_properties, inst_path, name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn,
|
||||
SyncbackSnapshot,
|
||||
filter_properties, inst_path, name_needs_slugify, slugify_name,
|
||||
strip_middleware_extension, FsSnapshot, SyncbackReturn, SyncbackSnapshot,
|
||||
},
|
||||
variant_eq::variant_eq,
|
||||
RojoRef,
|
||||
|
|
@ -658,9 +658,12 @@ pub fn syncback_project<'sync>(
|
|||
std::collections::HashSet<String>,
|
||||
> = std::collections::HashMap::new();
|
||||
|
||||
// Pre-seed taken_names from old children's slugified instance names
|
||||
// so that new-only children correctly dedup against existing siblings
|
||||
// (e.g., new "utils" won't collide with existing "Utils.luau").
|
||||
// Pre-seed taken_names from old children's actual filesystem dedup keys
|
||||
// so that new-only children correctly dedup against existing siblings.
|
||||
// We derive keys from relevant_paths (the real filename on disk) rather
|
||||
// than slugifying instance names, because an old instance may already
|
||||
// have a tilde suffix (e.g. A_B~1.luau from prior dedup) that the bare
|
||||
// slug wouldn't capture.
|
||||
// Only in incremental mode: in clean mode, old_ref is forced to None
|
||||
// for all children, so every child is treated as new and pre-seeding
|
||||
// would cause false collisions (spurious ~1 suffixes).
|
||||
|
|
@ -668,13 +671,24 @@ pub fn syncback_project<'sync>(
|
|||
if let Some(parent_path) = ref_to_path_map.get(&new_inst.referent()) {
|
||||
let taken = taken_names_per_dir.entry(parent_path.clone()).or_default();
|
||||
for old_child in old_child_map.values() {
|
||||
let name = old_child.name();
|
||||
let slug = if name_needs_slugify(name) {
|
||||
slugify_name(name).to_lowercase()
|
||||
if let Some(path) = old_child.metadata().relevant_paths.first() {
|
||||
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
|
||||
let middleware =
|
||||
old_child.metadata().middleware.unwrap_or(Middleware::Dir);
|
||||
let dedup_key =
|
||||
strip_middleware_extension(filename, middleware).to_lowercase();
|
||||
taken.insert(dedup_key);
|
||||
}
|
||||
} else {
|
||||
name.to_lowercase()
|
||||
};
|
||||
taken.insert(slug);
|
||||
// Fallback (shouldn't happen for old instances with disk presence)
|
||||
let name = old_child.name();
|
||||
let slug = if name_needs_slugify(name) {
|
||||
slugify_name(name).to_lowercase()
|
||||
} else {
|
||||
name.to_lowercase()
|
||||
};
|
||||
taken.insert(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ snapshot_id: "00000000000000000000000000000000"
|
|||
metadata:
|
||||
ignore_unknown_instances: false
|
||||
instigating_source:
|
||||
Path: /root
|
||||
Path: /root/init.csv
|
||||
relevant_paths:
|
||||
- /root
|
||||
- /root/init.luau
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ snapshot_id: "00000000000000000000000000000000"
|
|||
metadata:
|
||||
ignore_unknown_instances: false
|
||||
instigating_source:
|
||||
Path: /root
|
||||
Path: /root/init.csv
|
||||
relevant_paths:
|
||||
- /root
|
||||
- /root/init.luau
|
||||
|
|
|
|||
|
|
@ -2400,4 +2400,77 @@ mod tests {
|
|||
let dk_from_filename = strip_middleware_extension(&name1, Middleware::ModuleScript);
|
||||
assert_eq!(dk_from_filename.to_lowercase(), dk1.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syncback_forbidden_chars_produce_slugified_filenames() {
|
||||
// Verify that instances with forbidden chars produce correct
|
||||
// (filename, needs_meta, dedup_key) via name_for_inst
|
||||
let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder"));
|
||||
let root_ref = dom.root_ref();
|
||||
let child = dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("Hey/Bro"));
|
||||
let inst = dom.get_by_ref(child).unwrap();
|
||||
let taken = std::collections::HashSet::new();
|
||||
let (filename, needs_meta, dedup_key) = name_for_inst(
|
||||
crate::snapshot_middleware::Middleware::ModuleScript,
|
||||
inst, None, &taken,
|
||||
).unwrap();
|
||||
assert_eq!(filename.as_ref(), "Hey_Bro.luau");
|
||||
assert!(needs_meta);
|
||||
assert_eq!(dedup_key, "Hey_Bro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syncback_collision_deduplicates() {
|
||||
let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder"));
|
||||
let root_ref = dom.root_ref();
|
||||
let child1 = dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("A/B"));
|
||||
let child2 = dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("A:B"));
|
||||
let inst1 = dom.get_by_ref(child1).unwrap();
|
||||
let inst2 = dom.get_by_ref(child2).unwrap();
|
||||
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let (f1, m1, dk1) = name_for_inst(
|
||||
crate::snapshot_middleware::Middleware::ModuleScript,
|
||||
inst1, None, &taken,
|
||||
).unwrap();
|
||||
taken.insert(dk1.to_lowercase());
|
||||
let (f2, m2, dk2) = name_for_inst(
|
||||
crate::snapshot_middleware::Middleware::ModuleScript,
|
||||
inst2, None, &taken,
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(f1.as_ref(), "A_B.luau");
|
||||
assert!(m1);
|
||||
assert_eq!(f2.as_ref(), "A_B~1.luau");
|
||||
assert!(m2);
|
||||
assert_ne!(dk1.to_lowercase(), dk2.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syncback_idempotency_same_input_same_output() {
|
||||
// Run the same set of instances through name_for_inst twice
|
||||
// and verify identical output (determinism).
|
||||
let names = vec!["Alpha", "Beta/Gamma", "Beta:Gamma", "Delta", "CON"];
|
||||
let mw = crate::snapshot_middleware::Middleware::ModuleScript;
|
||||
|
||||
let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder"));
|
||||
let root = dom.root_ref();
|
||||
let refs: Vec<_> = names.iter().map(|n|
|
||||
dom.insert(root, rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name(*n))
|
||||
).collect();
|
||||
|
||||
let run = |dom: &rbx_dom_weak::WeakDom, refs: &[rbx_dom_weak::types::Ref]| -> Vec<(String, bool, String)> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
refs.iter().map(|r| {
|
||||
let inst = dom.get_by_ref(*r).unwrap();
|
||||
let (f, m, dk) = name_for_inst(mw, inst, None, &taken).unwrap();
|
||||
taken.insert(dk.to_lowercase());
|
||||
(f.into_owned(), m, dk)
|
||||
}).collect()
|
||||
};
|
||||
|
||||
let result1 = run(&dom, &refs);
|
||||
let result2 = run(&dom, &refs);
|
||||
assert_eq!(result1, result2, "Two runs of name_for_inst must produce identical output");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ use crate::{
|
|||
|
||||
pub use file_names::{
|
||||
adjacent_meta_path, deduplicate_name, extension_for_middleware, name_for_inst,
|
||||
name_needs_slugify, slugify_name, strip_middleware_extension, validate_file_name,
|
||||
name_needs_slugify, slugify_name, strip_middleware_extension, strip_script_suffix,
|
||||
validate_file_name,
|
||||
};
|
||||
pub use fs_snapshot::FsSnapshot;
|
||||
pub use hash::*;
|
||||
|
|
|
|||
267
src/web/api.rs
267
src/web/api.rs
|
|
@ -354,7 +354,7 @@ impl ApiService {
|
|||
// they are NOT removed from the in-memory tree.
|
||||
let mut actually_removed: Vec<Ref> = Vec::new();
|
||||
if !request.removed.is_empty() {
|
||||
let removal_actions: Vec<(Ref, Option<(PathBuf, bool)>)> = {
|
||||
let removal_actions: Vec<(Ref, Option<PathBuf>)> = {
|
||||
let tree = self.serve_session.tree();
|
||||
request
|
||||
.removed
|
||||
|
|
@ -364,7 +364,7 @@ impl ApiService {
|
|||
inst.metadata().instigating_source.as_ref().and_then(|src| {
|
||||
match src {
|
||||
crate::snapshot::InstigatingSource::Path(p) => {
|
||||
Some((p.clone(), p.is_dir()))
|
||||
Some(p.clone())
|
||||
}
|
||||
crate::snapshot::InstigatingSource::ProjectNode {
|
||||
name,
|
||||
|
|
@ -386,66 +386,9 @@ impl ApiService {
|
|||
|
||||
// Phase 2: Execute filesystem deletions without the lock
|
||||
for (id, action) in removal_actions {
|
||||
if let Some((path, is_dir)) = action {
|
||||
if !path.exists() {
|
||||
log::info!(
|
||||
"Syncback: Path already removed (likely parent was deleted): {}",
|
||||
path.display()
|
||||
);
|
||||
if let Some(path) = action {
|
||||
if self.remove_instance_at_path(&path) {
|
||||
actually_removed.push(id);
|
||||
continue;
|
||||
}
|
||||
if is_dir {
|
||||
self.suppress_path_remove(&path);
|
||||
if let Err(err) = fs::remove_dir_all(&path) {
|
||||
log::warn!(
|
||||
"Failed to remove directory {:?} for instance {:?}: {}",
|
||||
path,
|
||||
id,
|
||||
err
|
||||
);
|
||||
} else {
|
||||
log::info!("Syncback: Removed directory at {}", path.display());
|
||||
actually_removed.push(id);
|
||||
}
|
||||
} else {
|
||||
self.suppress_path_remove(&path);
|
||||
if let Err(err) = fs::remove_file(&path) {
|
||||
log::warn!(
|
||||
"Failed to remove file {:?} for instance {:?}: {}",
|
||||
path,
|
||||
id,
|
||||
err
|
||||
);
|
||||
} else {
|
||||
log::info!("Syncback: Removed file at {}", path.display());
|
||||
actually_removed.push(id);
|
||||
}
|
||||
// Also remove adjacent meta file.
|
||||
// Strip known script suffixes (.server, .client, etc.)
|
||||
// rather than splitting on dots, so that names containing
|
||||
// dots (e.g. "Config.Client.server.luau") resolve correctly.
|
||||
if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let base_name = file_stem
|
||||
.strip_suffix(".server")
|
||||
.or_else(|| file_stem.strip_suffix(".client"))
|
||||
.or_else(|| file_stem.strip_suffix(".plugin"))
|
||||
.or_else(|| file_stem.strip_suffix(".local"))
|
||||
.or_else(|| file_stem.strip_suffix(".legacy"))
|
||||
.unwrap_or(file_stem);
|
||||
if let Some(parent_dir) = path.parent() {
|
||||
let meta_path =
|
||||
parent_dir.join(format!("{}.meta.json5", base_name));
|
||||
if meta_path.exists() {
|
||||
self.suppress_path_remove(&meta_path);
|
||||
let _ = fs::remove_file(&meta_path);
|
||||
log::info!(
|
||||
"Syncback: Removed adjacent meta file at {}",
|
||||
meta_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1422,139 +1365,109 @@ impl ApiService {
|
|||
Ok(new_dir)
|
||||
}
|
||||
|
||||
/// Syncback a removed instance by deleting its file(s) from the filesystem.
|
||||
/// This is called when the user selects "pull" for an instance that exists in Rojo
|
||||
/// but has been deleted in Studio.
|
||||
#[allow(dead_code)]
|
||||
fn syncback_removed_instance(
|
||||
&self,
|
||||
removed_id: Ref,
|
||||
tree: &crate::snapshot::RojoTree,
|
||||
) -> anyhow::Result<()> {
|
||||
use anyhow::Context;
|
||||
/// Remove an instance from the filesystem given its path.
|
||||
///
|
||||
/// Handles three cases:
|
||||
/// - Directory: `remove_dir_all`
|
||||
/// - Init file (`init.luau`, `init.server.luau`, etc.): remove parent directory
|
||||
/// (which IS the instance) + grandparent-level adjacent meta
|
||||
/// - Regular file: remove the file + adjacent `.meta.json5`
|
||||
///
|
||||
/// Returns `true` if the removal succeeded (or the path was already gone),
|
||||
/// `false` if the filesystem operation failed (logged as warning).
|
||||
fn remove_instance_at_path(&self, path: &Path) -> bool {
|
||||
use crate::syncback::adjacent_meta_path;
|
||||
|
||||
// Find the instance in the tree
|
||||
let instance = tree
|
||||
.get_instance(removed_id)
|
||||
.context("Instance not found in Rojo tree")?;
|
||||
|
||||
// Get the instigating source and handle each variant appropriately
|
||||
let instigating_source = instance
|
||||
.metadata()
|
||||
.instigating_source
|
||||
.as_ref()
|
||||
.context("Instance has no filesystem path (not synced from filesystem)")?;
|
||||
|
||||
// Only delete instances that originate from filesystem paths, not from project nodes
|
||||
let instance_path = match instigating_source {
|
||||
InstigatingSource::Path(path) => path,
|
||||
InstigatingSource::ProjectNode { name, .. } => {
|
||||
// Instances defined in project files cannot be removed via syncback
|
||||
// because that would require modifying the project file structure
|
||||
log::warn!(
|
||||
"Syncback: Cannot remove instance '{}' (id: {:?}) - it is defined in a project file",
|
||||
name,
|
||||
removed_id
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Cannot remove instance '{}' because it is defined in a project file. \
|
||||
To remove this instance, edit the project file directly.",
|
||||
name
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if path exists - it may have already been deleted as part of a parent removal
|
||||
// (e.g., if both a directory and its children are marked for removal, the directory
|
||||
// deletion will recursively delete all children, so we just skip them)
|
||||
if !instance_path.exists() {
|
||||
log::trace!(
|
||||
if !path.exists() {
|
||||
log::info!(
|
||||
"Syncback: Path already removed (likely parent was deleted): {}",
|
||||
instance_path.display()
|
||||
path.display()
|
||||
);
|
||||
return Ok(());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delete the file or directory
|
||||
if instance_path.is_dir() {
|
||||
self.suppress_path_remove(instance_path);
|
||||
fs::remove_dir_all(instance_path).with_context(|| {
|
||||
format!("Failed to remove directory: {}", instance_path.display())
|
||||
})?;
|
||||
log::info!("Syncback: Removed directory at {}", instance_path.display());
|
||||
} else if instance_path.is_file() {
|
||||
use crate::syncback::adjacent_meta_path;
|
||||
if path.is_dir() {
|
||||
self.suppress_path_remove(path);
|
||||
if let Err(err) = fs::remove_dir_all(path) {
|
||||
log::warn!("Failed to remove directory {:?}: {}", path, err);
|
||||
return false;
|
||||
}
|
||||
log::info!("Syncback: Removed directory at {}", path.display());
|
||||
return true;
|
||||
}
|
||||
|
||||
let file_name = instance_path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
let is_init_file = file_name.starts_with("init.");
|
||||
// File: check if it's an init file (directory-format script)
|
||||
let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||
let is_init_file = file_name.starts_with("init.");
|
||||
|
||||
if is_init_file {
|
||||
// Directory-format script: the parent directory represents the
|
||||
// instance (e.g. src/MyModule/init.luau → the directory IS MyModule).
|
||||
// Remove the entire directory.
|
||||
let dir_path = instance_path.parent().unwrap();
|
||||
self.suppress_path_remove(dir_path);
|
||||
fs::remove_dir_all(dir_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to remove directory for init file: {}",
|
||||
dir_path.display()
|
||||
)
|
||||
})?;
|
||||
log::info!(
|
||||
"Syncback: Removed directory {} (init file: {})",
|
||||
dir_path.display(),
|
||||
instance_path.display()
|
||||
if is_init_file {
|
||||
// Directory-format script: the parent directory represents the
|
||||
// instance (e.g. src/MyModule/init.luau → the directory IS MyModule).
|
||||
// Remove the entire directory.
|
||||
let dir_path = path.parent().unwrap();
|
||||
self.suppress_path_remove(dir_path);
|
||||
if let Err(err) = fs::remove_dir_all(dir_path) {
|
||||
log::warn!(
|
||||
"Failed to remove directory for init file {:?}: {}",
|
||||
dir_path,
|
||||
err
|
||||
);
|
||||
return false;
|
||||
}
|
||||
log::info!(
|
||||
"Syncback: Removed directory {} (init file: {})",
|
||||
dir_path.display(),
|
||||
path.display()
|
||||
);
|
||||
|
||||
// Also remove adjacent dir-level meta in the grandparent if it
|
||||
// exists (e.g. grandparent/MyModule.meta.json5).
|
||||
if let Some(grandparent) = dir_path.parent() {
|
||||
if let Some(dir_name) = dir_path.file_name().and_then(|f| f.to_str()) {
|
||||
let dir_meta = grandparent.join(format!("{}.meta.json5", dir_name));
|
||||
if dir_meta.exists() {
|
||||
if let Err(err) = fs::remove_file(&dir_meta) {
|
||||
log::warn!(
|
||||
"Failed to remove dir-level meta file {}: {}",
|
||||
dir_meta.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
// Also remove adjacent dir-level meta in the grandparent if it
|
||||
// exists (e.g. grandparent/MyModule.meta.json5).
|
||||
if let Some(grandparent) = dir_path.parent() {
|
||||
if let Some(dir_name) = dir_path.file_name().and_then(|f| f.to_str()) {
|
||||
let dir_meta = grandparent.join(format!("{}.meta.json5", dir_name));
|
||||
if dir_meta.exists() {
|
||||
self.suppress_path_remove(&dir_meta);
|
||||
if let Err(err) = fs::remove_file(&dir_meta) {
|
||||
log::warn!(
|
||||
"Failed to remove dir-level meta file {}: {}",
|
||||
dir_meta.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular file: remove the file itself
|
||||
self.suppress_path_remove(instance_path);
|
||||
fs::remove_file(instance_path).with_context(|| {
|
||||
format!("Failed to remove file: {}", instance_path.display())
|
||||
})?;
|
||||
log::info!("Syncback: Removed file at {}", instance_path.display());
|
||||
}
|
||||
} else {
|
||||
// Regular file: remove the file itself
|
||||
self.suppress_path_remove(path);
|
||||
if let Err(err) = fs::remove_file(path) {
|
||||
log::warn!("Failed to remove file {:?}: {}", path, err);
|
||||
return false;
|
||||
}
|
||||
log::info!("Syncback: Removed file at {}", path.display());
|
||||
|
||||
// Remove the adjacent meta file if it exists. The meta file is
|
||||
// named after the script file's base stem (the slugified name),
|
||||
// not the raw instance name.
|
||||
let meta_path = adjacent_meta_path(instance_path);
|
||||
if meta_path.exists() {
|
||||
if let Err(err) = fs::remove_file(&meta_path) {
|
||||
log::warn!(
|
||||
"Failed to remove adjacent meta file {}: {}",
|
||||
meta_path.display(),
|
||||
err
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Syncback: Removed adjacent meta file at {}",
|
||||
meta_path.display()
|
||||
);
|
||||
}
|
||||
// Remove the adjacent meta file if it exists. The meta file is
|
||||
// named after the script file's base stem (the slugified name),
|
||||
// not the raw instance name.
|
||||
let meta_path = adjacent_meta_path(path);
|
||||
if meta_path.exists() {
|
||||
self.suppress_path_remove(&meta_path);
|
||||
if let Err(err) = fs::remove_file(&meta_path) {
|
||||
log::warn!(
|
||||
"Failed to remove adjacent meta file {}: {}",
|
||||
meta_path.display(),
|
||||
err
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Syncback: Removed adjacent meta file at {}",
|
||||
meta_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
true
|
||||
}
|
||||
|
||||
/// Check for duplicate names among children and return the set of unique children
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue