mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Update slugify branch audit plan and implement fixes for identified issues
- Marked all todos in the slugify branch audit plan as completed, confirming the successful verification of core invariants and sync processes. - Introduced a new plan to address critical bugs, correctness issues, and refactors identified during the audit, including enhancements to metadata handling and slugification logic. - Added tests to ensure proper handling of filenames with forbidden characters and deduplication of instances with colliding names. - Refactored metadata operations to improve clarity and maintainability, consolidating logic for reading and writing the `name` field in `.meta.json5` files.
This commit is contained in:
parent
d8175d3b48
commit
baf05dcb3d
14 changed files with 875 additions and 119 deletions
|
|
@ -226,88 +226,47 @@ impl JobThreadContext {
|
|||
}
|
||||
}
|
||||
|
||||
/// Upsert the `name` field in a `.meta.json5` file. If the file exists,
|
||||
/// parse it and add/update the `name` key. If it doesn't exist, create it
|
||||
/// with just the `name` key.
|
||||
/// 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) {
|
||||
self.suppress_path(meta_path);
|
||||
let mut obj = if meta_path.exists() {
|
||||
match fs::read(meta_path) {
|
||||
Ok(bytes) => match crate::json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
},
|
||||
Err(_) => serde_json::Map::new(),
|
||||
}
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
obj.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(real_name.to_string()),
|
||||
);
|
||||
match crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj)) {
|
||||
Ok(content) => {
|
||||
if let Err(err) = fs::write(meta_path, &content) {
|
||||
self.unsuppress_path(meta_path);
|
||||
log::error!(
|
||||
"Failed to write name to meta file {}: {}",
|
||||
meta_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.unsuppress_path(meta_path);
|
||||
log::error!("Failed to serialize meta for name field: {}", err);
|
||||
}
|
||||
if let Err(err) = crate::syncback::meta::upsert_meta_name(meta_path, real_name) {
|
||||
self.unsuppress_path(meta_path);
|
||||
log::error!(
|
||||
"Failed to upsert name in meta file {}: {}",
|
||||
meta_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the `name` field from a `.meta.json5` file if present.
|
||||
/// If the file becomes an empty object after removal, delete it entirely.
|
||||
/// This is called when renaming from a slugified name to a clean name,
|
||||
/// to clear the stale `name` field that no longer applies.
|
||||
/// Remove the `name` field from a `.meta.json5` file, suppressing filesystem
|
||||
/// events. If the file becomes empty after removal, deletes it entirely.
|
||||
fn remove_meta_name_field(&self, meta_path: &Path) {
|
||||
if !meta_path.exists() {
|
||||
return;
|
||||
}
|
||||
let bytes = match fs::read(meta_path) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut obj = match crate::json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => return,
|
||||
};
|
||||
if obj.remove("name").is_none() {
|
||||
return; // no name field, nothing to do
|
||||
}
|
||||
if obj.is_empty() {
|
||||
// Meta file only had the name field; delete it entirely
|
||||
self.suppress_path_any(meta_path);
|
||||
if let Err(err) = fs::remove_file(meta_path) {
|
||||
self.unsuppress_path_any(meta_path);
|
||||
use crate::syncback::meta::RemoveNameOutcome;
|
||||
// Suppress for the write/delete that may follow
|
||||
self.suppress_path(meta_path);
|
||||
match crate::syncback::meta::remove_meta_name(meta_path) {
|
||||
Ok(RemoveNameOutcome::NoOp) => {
|
||||
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);
|
||||
}
|
||||
Ok(RemoveNameOutcome::FieldRemoved) => {
|
||||
// File was rewritten — suppress_path already covers it.
|
||||
}
|
||||
Err(err) => {
|
||||
self.unsuppress_path(meta_path);
|
||||
log::error!(
|
||||
"Failed to remove empty meta file {}: {}",
|
||||
"Failed to remove name from meta file {}: {}",
|
||||
meta_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.suppress_path(meta_path);
|
||||
match crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj)) {
|
||||
Ok(content) => {
|
||||
if let Err(err) = fs::write(meta_path, &content) {
|
||||
self.unsuppress_path(meta_path);
|
||||
log::error!("Failed to write meta file {}: {}", meta_path.display(), err);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.unsuppress_path(meta_path);
|
||||
log::error!("Failed to serialize meta: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -732,6 +691,9 @@ impl JobThreadContext {
|
|||
let new_dir_path =
|
||||
grandparent.join(&slugified_new_name);
|
||||
|
||||
// Track the effective directory path after any rename
|
||||
let effective_dir_path;
|
||||
|
||||
if new_dir_path != dir_path {
|
||||
log::info!(
|
||||
"Two-way sync: Renaming directory {} -> {}",
|
||||
|
|
@ -751,6 +713,7 @@ impl JobThreadContext {
|
|||
new_dir_path,
|
||||
err
|
||||
);
|
||||
effective_dir_path = dir_path.to_path_buf();
|
||||
} else {
|
||||
// The init file moved with the directory.
|
||||
overridden_source_path =
|
||||
|
|
@ -774,18 +737,24 @@ impl JobThreadContext {
|
|||
self.unsuppress_path(&new_meta);
|
||||
}
|
||||
}
|
||||
let init_meta =
|
||||
new_dir_path.join("init.meta.json5");
|
||||
if slugified_new_name != *new_name {
|
||||
// New name needs slugification — write name field
|
||||
self.upsert_meta_name_field(
|
||||
&init_meta, new_name,
|
||||
);
|
||||
} else {
|
||||
// Clean name — clear stale name field
|
||||
self.remove_meta_name_field(&init_meta);
|
||||
}
|
||||
effective_dir_path = new_dir_path.clone();
|
||||
}
|
||||
} else {
|
||||
effective_dir_path = dir_path.to_path_buf();
|
||||
}
|
||||
|
||||
// Always update the meta name field when the
|
||||
// instance name changed, even if the directory
|
||||
// path didn't change (e.g. "Foo/Bar" → "Foo|Bar"
|
||||
// both slugify to "Foo_Bar").
|
||||
let init_meta =
|
||||
effective_dir_path.join("init.meta.json5");
|
||||
if slugified_new_name != *new_name {
|
||||
self.upsert_meta_name_field(
|
||||
&init_meta, new_name,
|
||||
);
|
||||
} else {
|
||||
self.remove_meta_name_field(&init_meta);
|
||||
}
|
||||
}
|
||||
} else if let Some(parent) = path.parent() {
|
||||
|
|
@ -832,6 +801,9 @@ impl JobThreadContext {
|
|||
};
|
||||
let new_path = parent.join(&new_file_name);
|
||||
|
||||
// Track the effective meta base name after any rename
|
||||
let effective_meta_base: &str;
|
||||
|
||||
if new_path != *path {
|
||||
log::info!(
|
||||
"Two-way sync: Renaming {} -> {}",
|
||||
|
|
@ -849,6 +821,7 @@ impl JobThreadContext {
|
|||
new_path,
|
||||
err
|
||||
);
|
||||
effective_meta_base = old_base;
|
||||
} else {
|
||||
overridden_source_path = Some(new_path.clone());
|
||||
let old_meta = parent
|
||||
|
|
@ -866,16 +839,27 @@ impl JobThreadContext {
|
|||
self.unsuppress_path(&new_meta);
|
||||
}
|
||||
}
|
||||
if slugified_new_name != *new_name {
|
||||
// New name needs slugification — write name field
|
||||
self.upsert_meta_name_field(
|
||||
&new_meta, new_name,
|
||||
);
|
||||
} else {
|
||||
// Clean name — clear stale name field
|
||||
self.remove_meta_name_field(&new_meta);
|
||||
}
|
||||
effective_meta_base = &slugified_new_name;
|
||||
}
|
||||
} else {
|
||||
effective_meta_base = old_base;
|
||||
}
|
||||
|
||||
// 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").
|
||||
let current_meta = parent.join(format!(
|
||||
"{}.meta.json5",
|
||||
effective_meta_base
|
||||
));
|
||||
if slugified_new_name != *new_name {
|
||||
self.upsert_meta_name_field(
|
||||
¤t_meta,
|
||||
new_name,
|
||||
);
|
||||
} else {
|
||||
self.remove_meta_name_field(¤t_meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -679,7 +679,12 @@ pub fn syncback_project<'sync>(
|
|||
}
|
||||
}
|
||||
|
||||
for (name, new_child) in new_child_map.drain() {
|
||||
// Sort remaining children by name for deterministic dedup ordering.
|
||||
// Without this, HashMap iteration order determines which sibling gets
|
||||
// the base slug vs ~1, causing non-deterministic output across runs.
|
||||
let mut remaining_children: Vec<_> = new_child_map.drain().collect();
|
||||
remaining_children.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
|
||||
for (name, new_child) in remaining_children {
|
||||
// Skip instances of ignored classes
|
||||
if snapshot.should_ignore_class(&new_child.class) {
|
||||
// Also remove from old_child_map so it won't be marked as removed
|
||||
|
|
|
|||
|
|
@ -321,6 +321,36 @@ pub fn validate_file_name<S: AsRef<str>>(name: S) -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Known script suffixes that appear between the base name and file extension.
|
||||
/// For example, in `MyScript.server.luau`, `.server` is the suffix.
|
||||
const KNOWN_SCRIPT_SUFFIXES: &[&str] = &[".server", ".client", ".plugin", ".local", ".legacy"];
|
||||
|
||||
/// Strips a known script suffix from a file stem.
|
||||
///
|
||||
/// For example, `"MyScript.server"` → `"MyScript"`, but `"MyScript"` → `"MyScript"`.
|
||||
pub fn strip_script_suffix(stem: &str) -> &str {
|
||||
for suffix in KNOWN_SCRIPT_SUFFIXES {
|
||||
if let Some(base) = stem.strip_suffix(suffix) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
stem
|
||||
}
|
||||
|
||||
/// Given a script file path like `parent/Foo_Bar.server.luau`,
|
||||
/// returns the adjacent meta path `parent/Foo_Bar.meta.json5`.
|
||||
///
|
||||
/// Strips the file extension and any known script suffix (`.server`, `.client`,
|
||||
/// etc.) to derive the base name, then appends `.meta.json5`.
|
||||
pub fn adjacent_meta_path(script_path: &std::path::Path) -> std::path::PathBuf {
|
||||
let stem = script_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
let base = strip_script_suffix(stem);
|
||||
script_path.with_file_name(format!("{}.meta.json5", base))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -2182,4 +2212,192 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── strip_script_suffix ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn strip_suffix_server() {
|
||||
assert_eq!(strip_script_suffix("MyScript.server"), "MyScript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_suffix_client() {
|
||||
assert_eq!(strip_script_suffix("MyScript.client"), "MyScript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_suffix_plugin() {
|
||||
assert_eq!(strip_script_suffix("MyScript.plugin"), "MyScript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_suffix_local() {
|
||||
assert_eq!(strip_script_suffix("MyScript.local"), "MyScript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_suffix_legacy() {
|
||||
assert_eq!(strip_script_suffix("MyScript.legacy"), "MyScript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_suffix_none() {
|
||||
assert_eq!(strip_script_suffix("MyModule"), "MyModule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_suffix_dots_in_name() {
|
||||
assert_eq!(strip_script_suffix("v1.0.server"), "v1.0");
|
||||
}
|
||||
|
||||
// ── adjacent_meta_path ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn adjacent_meta_server_script() {
|
||||
let path = std::path::Path::new("src/Key_Script.server.luau");
|
||||
let meta = adjacent_meta_path(path);
|
||||
assert_eq!(meta, std::path::PathBuf::from("src/Key_Script.meta.json5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_meta_module_script() {
|
||||
let path = std::path::Path::new("src/Helper.luau");
|
||||
let meta = adjacent_meta_path(path);
|
||||
assert_eq!(meta, std::path::PathBuf::from("src/Helper.meta.json5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_meta_client_script() {
|
||||
let path = std::path::Path::new("src/Gui.client.luau");
|
||||
let meta = adjacent_meta_path(path);
|
||||
assert_eq!(meta, std::path::PathBuf::from("src/Gui.meta.json5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_meta_legacy_lua() {
|
||||
let path = std::path::Path::new("src/Old.server.lua");
|
||||
let meta = adjacent_meta_path(path);
|
||||
assert_eq!(meta, std::path::PathBuf::from("src/Old.meta.json5"));
|
||||
}
|
||||
|
||||
// ── tilde dedup end-to-end (unit level) ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tilde_dedup_collision_roundtrip() {
|
||||
// Two instances "A/B" and "A_B" both slugify to "A_B".
|
||||
// First gets "A_B", second gets "A_B~1".
|
||||
let mut taken = HashSet::new();
|
||||
|
||||
let dom1 = make_inst("A/B", "ModuleScript");
|
||||
let child1_ref = dom1.root().children()[0];
|
||||
let child1 = dom1.get_by_ref(child1_ref).unwrap();
|
||||
let (name1, meta1, dk1) =
|
||||
name_for_inst(Middleware::ModuleScript, child1, None, &taken).unwrap();
|
||||
taken.insert(dk1.to_lowercase());
|
||||
|
||||
let dom2 = make_inst("A_B", "ModuleScript");
|
||||
let child2_ref = dom2.root().children()[0];
|
||||
let child2 = dom2.get_by_ref(child2_ref).unwrap();
|
||||
let (name2, meta2, dk2) =
|
||||
name_for_inst(Middleware::ModuleScript, child2, None, &taken).unwrap();
|
||||
taken.insert(dk2.to_lowercase());
|
||||
|
||||
assert_eq!(name1.as_ref(), "A_B.luau");
|
||||
assert!(meta1, "A/B was slugified, needs meta name");
|
||||
assert_eq!(name2.as_ref(), "A_B~1.luau");
|
||||
assert!(meta2, "A_B was deduped to ~1, needs meta name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_in_filename_not_parsed_as_dedup_marker() {
|
||||
// A file named "Foo~1" should produce instance name "Foo~1",
|
||||
// NOT be interpreted as "Foo" with dedup suffix ~1.
|
||||
// This is verified by the fact that name_needs_slugify("Foo~1")
|
||||
// returns true (tilde is in SLUGIFY_CHARS), so a file named
|
||||
// "Foo~1.luau" can only exist if it was written by the dedup
|
||||
// system. Forward sync reads the filename as-is.
|
||||
assert!(
|
||||
name_needs_slugify("Foo~1"),
|
||||
"tilde should trigger slugification"
|
||||
);
|
||||
}
|
||||
|
||||
// ── stress: large batch with collisions ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stress_100_instances_deterministic() {
|
||||
// Create 100 instances where groups of 5 share the same slug.
|
||||
// Run dedup twice and verify the results are identical.
|
||||
let forbidden_chars = ['/', ':', '?', '|', '<', '>', '"', '*', '\\'];
|
||||
let mut names = Vec::new();
|
||||
for i in 0..100 {
|
||||
let group = i / 5;
|
||||
let variant = i % 5;
|
||||
// Each group of 5 uses a different forbidden char so they all
|
||||
// slugify to "Group_XX" but have different real names.
|
||||
let ch = forbidden_chars[variant % forbidden_chars.len()];
|
||||
names.push(format!("Group{ch}{:02}", group));
|
||||
}
|
||||
// Sort names alphabetically to simulate deterministic processing
|
||||
names.sort();
|
||||
|
||||
let run = |names: &[String]| -> Vec<(String, bool, String)> {
|
||||
let mut taken = HashSet::new();
|
||||
let mut results = Vec::new();
|
||||
for name in names {
|
||||
let dom = make_inst(name, "ModuleScript");
|
||||
let child_ref = dom.root().children()[0];
|
||||
let child = dom.get_by_ref(child_ref).unwrap();
|
||||
let (filename, needs_meta, dk) =
|
||||
name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap();
|
||||
taken.insert(dk.to_lowercase());
|
||||
results.push((filename.into_owned(), needs_meta, dk));
|
||||
}
|
||||
results
|
||||
};
|
||||
|
||||
let run1 = run(&names);
|
||||
let run2 = run(&names);
|
||||
assert_eq!(
|
||||
run1, run2,
|
||||
"dedup results must be deterministic across runs"
|
||||
);
|
||||
|
||||
// Verify no duplicate filenames
|
||||
let filenames: HashSet<String> = run1.iter().map(|(f, _, _)| f.to_lowercase()).collect();
|
||||
assert_eq!(
|
||||
filenames.len(),
|
||||
100,
|
||||
"all 100 instances should have unique filenames"
|
||||
);
|
||||
}
|
||||
|
||||
// ── idempotency: second pass produces same results ───────────────
|
||||
|
||||
#[test]
|
||||
fn idempotency_old_inst_preserves_path() {
|
||||
// When an old instance exists with a path, name_for_inst should
|
||||
// return the same path regardless of taken_names.
|
||||
let dom = make_inst("Hey/Bro", "ModuleScript");
|
||||
let child_ref = dom.root().children()[0];
|
||||
let child = dom.get_by_ref(child_ref).unwrap();
|
||||
|
||||
// First pass: new instance
|
||||
let mut taken = HashSet::new();
|
||||
let (name1, meta1, dk1) =
|
||||
name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap();
|
||||
taken.insert(dk1.to_lowercase());
|
||||
|
||||
assert_eq!(name1.as_ref(), "Hey_Bro.luau");
|
||||
assert!(meta1);
|
||||
|
||||
// On the old-inst path the existing filename is preserved,
|
||||
// so a second syncback (incremental) should not change the name.
|
||||
// We can't easily test the full old-inst path here since it
|
||||
// requires InstanceWithMeta, but we verify the dedup_key is
|
||||
// stable: calling strip_middleware_extension on the result gives
|
||||
// the same dedup_key.
|
||||
let dk_from_filename = strip_middleware_extension(&name1, Middleware::ModuleScript);
|
||||
assert_eq!(dk_from_filename.to_lowercase(), dk1.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/syncback/meta.rs
Normal file
76
src/syncback/meta.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! Shared helpers for reading and writing the `name` field in `.meta.json5` files.
|
||||
//!
|
||||
//! These are used by both `change_processor.rs` (two-way sync renames) and
|
||||
//! `web/api.rs` (syncback added/removed instances) to keep meta file handling
|
||||
//! DRY and consistent.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Upsert the `name` field in a `.meta.json5` file.
|
||||
///
|
||||
/// If the file exists, parses it and merges the `name` key (preserving other
|
||||
/// fields like `ignoreUnknownInstances`). If it doesn't exist, creates a new
|
||||
/// file with just the `name` key.
|
||||
///
|
||||
/// Returns `Ok(true)` if the file was written, `Ok(false)` if serialization
|
||||
/// failed (logged), or `Err` on I/O failure.
|
||||
pub fn upsert_meta_name(meta_path: &Path, real_name: &str) -> anyhow::Result<()> {
|
||||
let mut obj = if meta_path.exists() {
|
||||
match fs::read(meta_path) {
|
||||
Ok(bytes) => match crate::json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
},
|
||||
Err(_) => serde_json::Map::new(),
|
||||
}
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
obj.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(real_name.to_string()),
|
||||
);
|
||||
let content = crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj))?;
|
||||
fs::write(meta_path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Outcome of attempting to remove the `name` field from a meta file.
|
||||
pub enum RemoveNameOutcome {
|
||||
/// The meta file didn't exist or had no `name` field -- nothing changed.
|
||||
NoOp,
|
||||
/// The `name` field was removed; other fields remain. File was rewritten.
|
||||
FieldRemoved,
|
||||
/// The `name` field was the only field. The file was deleted entirely.
|
||||
FileDeleted,
|
||||
}
|
||||
|
||||
/// Remove the `name` field from a `.meta.json5` file.
|
||||
///
|
||||
/// If the file becomes an empty object after removal, deletes it entirely.
|
||||
/// Returns the outcome so callers can manage filesystem event suppression.
|
||||
pub fn remove_meta_name(meta_path: &Path) -> anyhow::Result<RemoveNameOutcome> {
|
||||
if !meta_path.exists() {
|
||||
return Ok(RemoveNameOutcome::NoOp);
|
||||
}
|
||||
let bytes = match fs::read(meta_path) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Ok(RemoveNameOutcome::NoOp),
|
||||
};
|
||||
let mut obj = match crate::json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => return Ok(RemoveNameOutcome::NoOp),
|
||||
};
|
||||
if obj.remove("name").is_none() {
|
||||
return Ok(RemoveNameOutcome::NoOp);
|
||||
}
|
||||
if obj.is_empty() {
|
||||
fs::remove_file(meta_path)?;
|
||||
Ok(RemoveNameOutcome::FileDeleted)
|
||||
} else {
|
||||
let content = crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj))?;
|
||||
fs::write(meta_path, &content)?;
|
||||
Ok(RemoveNameOutcome::FieldRemoved)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod file_names;
|
||||
mod fs_snapshot;
|
||||
mod hash;
|
||||
pub mod meta;
|
||||
mod property_filter;
|
||||
mod ref_properties;
|
||||
mod snapshot;
|
||||
|
|
@ -30,8 +31,8 @@ use crate::{
|
|||
};
|
||||
|
||||
pub use file_names::{
|
||||
deduplicate_name, extension_for_middleware, name_for_inst, name_needs_slugify, slugify_name,
|
||||
strip_middleware_extension, validate_file_name,
|
||||
adjacent_meta_path, deduplicate_name, extension_for_middleware, name_for_inst,
|
||||
name_needs_slugify, slugify_name, strip_middleware_extension, validate_file_name,
|
||||
};
|
||||
pub use fs_snapshot::FsSnapshot;
|
||||
pub use hash::*;
|
||||
|
|
@ -963,7 +964,7 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncbackRules {
|
||||
/// A list of subtrees in a file that will be ignored by Syncback.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
|
|
|
|||
|
|
@ -1438,8 +1438,6 @@ impl ApiService {
|
|||
.get_instance(removed_id)
|
||||
.context("Instance not found in Rojo tree")?;
|
||||
|
||||
let instance_name = instance.name();
|
||||
|
||||
// Get the instigating source and handle each variant appropriately
|
||||
let instigating_source = instance
|
||||
.metadata()
|
||||
|
|
@ -1485,16 +1483,60 @@ impl ApiService {
|
|||
})?;
|
||||
log::info!("Syncback: Removed directory at {}", instance_path.display());
|
||||
} else if instance_path.is_file() {
|
||||
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());
|
||||
use crate::syncback::adjacent_meta_path;
|
||||
|
||||
// Also remove adjacent meta file if it exists
|
||||
// The meta file is named after the instance name, not the file name
|
||||
// e.g., for "MyScript.server.luau", the meta file is "MyScript.meta.json5"
|
||||
if let Some(parent_dir) = instance_path.parent() {
|
||||
let meta_path = parent_dir.join(format!("{}.meta.json5", instance_name));
|
||||
let file_name = instance_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()
|
||||
);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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());
|
||||
|
||||
// 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!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue