Enhance syncback functionality by implementing tree filtering based on ignoreTrees patterns. Added support for glob pattern matching to filter instances during serialization in both rbxm and rbxmx. Updated SyncbackSnapshot to compile and utilize these patterns efficiently.

This commit is contained in:
Joe 2025-12-29 00:02:40 -06:00
parent cf5f7788fc
commit f6dc37e400
4 changed files with 172 additions and 29 deletions

View file

@ -2,10 +2,12 @@ use std::path::Path;
use anyhow::Context; use anyhow::Context;
use memofs::Vfs; use memofs::Vfs;
use rbx_dom_weak::{types::Ref, InstanceBuilder, WeakDom};
use crate::{ use crate::{
glob::Glob,
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, syncback::{inst_path, FsSnapshot, SyncbackReturn, SyncbackSnapshot},
}; };
#[profiling::function] #[profiling::function]
@ -46,12 +48,26 @@ pub fn syncback_rbxm<'sync>(
snapshot: &SyncbackSnapshot<'sync>, snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> { ) -> anyhow::Result<SyncbackReturn<'sync>> {
let inst = snapshot.new_inst(); let inst = snapshot.new_inst();
let tree_globs = snapshot.compile_tree_globs();
// Long-term, we probably want to have some logic for if this contains a // If we have ignoreTrees patterns, filter the tree before serialization
// script. That's a future endeavor though. let serialized = if tree_globs.is_empty() {
let mut serialized = Vec::new(); let mut serialized = Vec::new();
rbx_binary::to_writer(&mut serialized, snapshot.new_tree(), &[inst.referent()]) rbx_binary::to_writer(&mut serialized, snapshot.new_tree(), &[inst.referent()])
.context("failed to serialize new rbxm")?; .context("failed to serialize new rbxm")?;
serialized
} else {
// Clone the subtree, filtering out ignored instances
let filtered_tree = clone_tree_filtered(
snapshot.new_tree(),
inst.referent(),
&tree_globs,
);
let mut serialized = Vec::new();
rbx_binary::to_writer(&mut serialized, &filtered_tree, &[filtered_tree.root_ref()])
.context("failed to serialize filtered rbxm")?;
serialized
};
Ok(SyncbackReturn { Ok(SyncbackReturn {
fs_snapshot: FsSnapshot::new().with_added_file(&snapshot.path, serialized), fs_snapshot: FsSnapshot::new().with_added_file(&snapshot.path, serialized),
@ -60,6 +76,55 @@ pub fn syncback_rbxm<'sync>(
}) })
} }
/// Clones a subtree from the source WeakDom, filtering out instances that match
/// the provided ignoreTrees glob patterns.
pub fn clone_tree_filtered(source: &WeakDom, root_ref: Ref, ignore_globs: &[Glob]) -> WeakDom {
let root_inst = source.get_by_ref(root_ref).expect("root ref should exist");
// Create a new tree with the root instance
let mut builder = InstanceBuilder::new(root_inst.class)
.with_name(root_inst.name.clone())
.with_properties(root_inst.properties.clone());
// Recursively add children, filtering out ignored ones
add_children_filtered(source, root_ref, &mut builder, ignore_globs);
WeakDom::new(builder)
}
/// Recursively adds children to an InstanceBuilder, filtering out ignored instances.
fn add_children_filtered(
source: &WeakDom,
parent_ref: Ref,
builder: &mut InstanceBuilder,
ignore_globs: &[Glob],
) {
let parent = source.get_by_ref(parent_ref).expect("parent ref should exist");
for &child_ref in parent.children() {
let child_path = inst_path(source, child_ref);
// Check if this child should be ignored
let should_ignore = ignore_globs.iter().any(|glob| glob.is_match(&child_path));
if should_ignore {
log::debug!("Filtering out {child_path} from rbxm due to ignoreTrees pattern");
continue;
}
let child = source.get_by_ref(child_ref).expect("child ref should exist");
let mut child_builder = InstanceBuilder::new(child.class)
.with_name(child.name.clone())
.with_properties(child.properties.clone());
// Recursively add this child's children
add_children_filtered(source, child_ref, &mut child_builder, ignore_globs);
builder.add_child(child_builder);
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View file

@ -9,6 +9,8 @@ use crate::{
syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot},
}; };
use super::rbxm::clone_tree_filtered;
pub fn snapshot_rbxmx( pub fn snapshot_rbxmx(
context: &InstanceContext, context: &InstanceContext,
vfs: &Vfs, vfs: &Vfs,
@ -49,20 +51,39 @@ pub fn syncback_rbxmx<'sync>(
snapshot: &SyncbackSnapshot<'sync>, snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> { ) -> anyhow::Result<SyncbackReturn<'sync>> {
let inst = snapshot.new_inst(); let inst = snapshot.new_inst();
let tree_globs = snapshot.compile_tree_globs();
let options = let options =
EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown); EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown);
// Long-term, we probably want to have some logic for if this contains a // If we have ignoreTrees patterns, filter the tree before serialization
// script. That's a future endeavor though. let serialized = if tree_globs.is_empty() {
let mut serialized = Vec::new(); let mut serialized = Vec::new();
rbx_xml::to_writer( rbx_xml::to_writer(
&mut serialized, &mut serialized,
snapshot.new_tree(), snapshot.new_tree(),
&[inst.referent()], &[inst.referent()],
options, options,
) )
.context("failed to serialize new rbxmx")?; .context("failed to serialize new rbxmx")?;
serialized
} else {
// Clone the subtree, filtering out ignored instances
let filtered_tree = clone_tree_filtered(
snapshot.new_tree(),
inst.referent(),
&tree_globs,
);
let mut serialized = Vec::new();
rbx_xml::to_writer(
&mut serialized,
&filtered_tree,
&[filtered_tree.root_ref()],
options,
)
.context("failed to serialize filtered rbxmx")?;
serialized
};
Ok(SyncbackReturn { Ok(SyncbackReturn {
fs_snapshot: FsSnapshot::new().with_added_file(&snapshot.path, serialized), fs_snapshot: FsSnapshot::new().with_added_file(&snapshot.path, serialized),

View file

@ -32,7 +32,7 @@ pub use file_names::{extension_for_middleware, name_for_inst, validate_file_name
pub use fs_snapshot::FsSnapshot; pub use fs_snapshot::FsSnapshot;
pub use hash::*; pub use hash::*;
pub use property_filter::{filter_properties, filter_properties_preallocated}; pub use property_filter::{filter_properties, filter_properties_preallocated};
pub use snapshot::{SyncbackData, SyncbackSnapshot}; pub use snapshot::{inst_path, SyncbackData, SyncbackSnapshot};
/// The name of an enviroment variable to use to override the behavior of /// The name of an enviroment variable to use to override the behavior of
/// syncback on model files. /// syncback on model files.
@ -59,6 +59,12 @@ pub fn syncback_loop(
.map(|rules| rules.compile_globs()) .map(|rules| rules.compile_globs())
.transpose()?; .transpose()?;
let tree_globs = project
.syncback_rules
.as_ref()
.map(|rules| rules.compile_tree_globs())
.transpose()?;
// TODO: Add a better way to tell if the root of a project is a directory // TODO: Add a better way to tell if the root of a project is a directory
let skip_pruning = if let Some(path) = &project.tree.path { let skip_pruning = if let Some(path) = &project.tree.path {
let middleware = let middleware =
@ -187,11 +193,11 @@ pub fn syncback_loop(
log::debug!("Skipping {inst_path} because its path matches ignore pattern"); log::debug!("Skipping {inst_path} because its path matches ignore pattern");
continue; continue;
} }
if let Some(syncback_rules) = &project.syncback_rules { // Check ignoreTrees with glob pattern support
// Ignore trees; if let Some(ref globs) = tree_globs {
for ignored in &syncback_rules.ignore_trees { for glob in globs {
if inst_path.starts_with(ignored.as_str()) { if glob.is_match(&inst_path) {
log::debug!("Tree {inst_path} is blocked by project"); log::debug!("Tree {inst_path} is blocked by ignoreTrees glob pattern");
continue 'syncback; continue 'syncback;
} }
} }
@ -261,10 +267,11 @@ pub fn syncback_loop(
); );
continue; continue;
} }
if let Some(syncback_rules) = &project.syncback_rules { // Check ignoreTrees with glob pattern support
for ignored in &syncback_rules.ignore_trees { if let Some(ref globs) = tree_globs {
if inst_path.starts_with(ignored.as_str()) { for glob in globs {
log::debug!("Skipping removing {inst_path} because its path is blocked by project"); if glob.is_match(&inst_path) {
log::debug!("Skipping removing {inst_path} because its path is blocked by ignoreTrees glob pattern");
continue 'remove; continue 'remove;
} }
} }
@ -439,6 +446,20 @@ impl SyncbackRules {
Ok(globs) Ok(globs)
} }
/// Compiles the ignoreTrees patterns into glob matchers.
/// Supports glob patterns like `**/Abc/Script` for flexible matching.
pub fn compile_tree_globs(&self) -> anyhow::Result<Vec<Glob>> {
let mut globs = Vec::with_capacity(self.ignore_trees.len());
for pattern in &self.ignore_trees {
let glob = Glob::new(pattern)
.with_context(|| format!("the pattern '{pattern}' is not a valid ignoreTrees glob"))?;
globs.push(glob);
}
Ok(globs)
}
/// Returns whether Windows-invalid characters should be encoded in file /// Returns whether Windows-invalid characters should be encoded in file
/// names during syncback. Defaults to `false`. /// names during syncback. Defaults to `false`.
#[inline] #[inline]

View file

@ -3,6 +3,7 @@ use memofs::Vfs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::{ use crate::{
glob::Glob,
snapshot::{InstanceWithMeta, RojoTree}, snapshot::{InstanceWithMeta, RojoTree},
snapshot_middleware::Middleware, snapshot_middleware::Middleware,
Project, Project,
@ -239,13 +240,20 @@ impl<'sync> SyncbackSnapshot<'sync> {
/// Checks if an instance should be ignored based on ignoreTrees rules. /// Checks if an instance should be ignored based on ignoreTrees rules.
/// Takes a Ref to check against the new tree's instance path. /// Takes a Ref to check against the new tree's instance path.
/// Supports glob patterns like `**/Abc/Script`.
#[inline] #[inline]
pub fn should_ignore_tree(&self, referent: Ref) -> bool { pub fn should_ignore_tree(&self, referent: Ref) -> bool {
if let Some(ignore_trees) = self.ignore_tree() { if let Some(ignore_trees) = self.ignore_tree() {
let path = inst_path(self.new_tree(), referent); let path = inst_path(self.new_tree(), referent);
for ignored in ignore_trees { for pattern in ignore_trees {
if path.starts_with(ignored.as_str()) { // Try glob matching first, fall back to prefix matching for backwards compatibility
log::debug!("Ignoring {path} because it matches ignoreTrees entry: {ignored}"); if let Ok(glob) = Glob::new(pattern) {
if glob.is_match(&path) {
log::debug!("Ignoring {path} because it matches ignoreTrees glob: {pattern}");
return true;
}
} else if path.starts_with(pattern.as_str()) {
log::debug!("Ignoring {path} because it matches ignoreTrees prefix: {pattern}");
return true; return true;
} }
} }
@ -253,6 +261,34 @@ impl<'sync> SyncbackSnapshot<'sync> {
false false
} }
/// Checks if an instance should be ignored using pre-compiled globs.
/// More efficient when checking many instances against the same patterns.
#[inline]
pub fn should_ignore_tree_with_globs(&self, referent: Ref, globs: &[Glob]) -> bool {
if globs.is_empty() {
return false;
}
let path = inst_path(self.new_tree(), referent);
for glob in globs {
if glob.is_match(&path) {
log::debug!("Ignoring {path} because it matches ignoreTrees glob pattern");
return true;
}
}
false
}
/// Compiles the ignoreTrees patterns into globs.
/// Returns an empty Vec if no patterns are configured.
pub fn compile_tree_globs(&self) -> Vec<Glob> {
self.data
.project
.syncback_rules
.as_ref()
.and_then(|rules| rules.compile_tree_globs().ok())
.unwrap_or_default()
}
/// Returns whether Windows-invalid characters should be encoded in file /// Returns whether Windows-invalid characters should be encoded in file
/// names during syncback. /// names during syncback.
#[inline] #[inline]