Implement incremental syncback mode to preserve existing file structure

- Added an `incremental` flag to the `SyncbackCommand` to allow for preserving existing file structure and middleware formats during syncback operations.
- Enhanced the `syncback_loop` function to differentiate between clean mode and incremental mode, ensuring orphaned files are removed only in clean mode.
- Updated logging to reflect the mode of operation during syncback.
- Introduced new tests to validate the behavior of clean mode versus incremental mode, ensuring proper handling of orphaned files and preservation of existing structures.
This commit is contained in:
Joe 2026-02-01 08:14:20 -06:00
parent 0e5e809894
commit 58399bb863
39 changed files with 652 additions and 119 deletions

View file

@ -2,4 +2,4 @@
source: tests/rojo_test/syncback_util.rs source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)" expression: "String::from_utf8_lossy(&output.stdout)"
--- ---
Removing C:/Users/Joe/AppData/Local/Temp/.tmpGY4Eb8/ignore_trees_removing/src

View file

@ -4,3 +4,5 @@ expression: "String::from_utf8_lossy(&output.stdout)"
--- ---
Writing src/modules/ClientModule.luau Writing src/modules/ClientModule.luau
Writing src/modules/ServerModule.luau Writing src/modules/ServerModule.luau
Removing C:/Users/Joe/AppData/Local/Temp/.tmpLKd1Ar/nested_projects_weird/client-only.project.json5
Removing C:/Users/Joe/AppData/Local/Temp/.tmpLKd1Ar/nested_projects_weird/server-only.project.json5

View file

@ -3,5 +3,6 @@ source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)" expression: "String::from_utf8_lossy(&output.stdout)"
--- ---
Writing src/ChildWithoutDuplicates/Child/.gitkeep Writing src/ChildWithoutDuplicates/Child/.gitkeep
Writing src/ChildWithDuplicates
Writing src/ChildWithoutDuplicates Writing src/ChildWithoutDuplicates
Writing src/ChildWithoutDuplicates/Child Writing src/ChildWithoutDuplicates/Child

View file

@ -4,4 +4,6 @@ expression: "String::from_utf8_lossy(&output.stdout)"
--- ---
Writing src/Pointer_1.model.json5 Writing src/Pointer_1.model.json5
Writing src/Pointer_2.model.json5 Writing src/Pointer_2.model.json5
Writing src/Target.model.json5 Writing src/Target/init.meta.json5
Writing src/Target
Removing C:/Users/Joe/AppData/Local/Temp/.tmpA4y8vf/ref_properties_duplicate/src/Target.model.json5

View file

@ -7,19 +7,19 @@ expression: container.model.json5
{ {
attributes: { attributes: {
Rojo_Id: "value_1", Rojo_Id: "value_1",
Rojo_Ref_Value: "ReplicatedStorage/container/value_1", Rojo_Ref_Value: "ReplicatedStorage/container/value_1"
}, },
className: "ObjectValue", className: "ObjectValue",
name: "value_1", name: "value_1"
}, },
{ {
attributes: { attributes: {
Rojo_Id: "value_1", Rojo_Id: "value_1",
Rojo_Ref_Value: "ReplicatedStorage/container/value_2", Rojo_Ref_Value: "ReplicatedStorage/container/value_2"
}, },
className: "ObjectValue", className: "ObjectValue",
name: "value_2", name: "value_2"
}, }
], ],
className: "Folder", className: "Folder"
} }

View file

@ -5,6 +5,6 @@ expression: src/int_value.model.json5
{ {
className: "IntValue", className: "IntValue",
properties: { properties: {
Value: 1337, Value: 1337
}, }
} }

View file

@ -5,6 +5,6 @@ expression: src/Message.model.json5
{ {
className: "Message", className: "Message",
properties: { properties: {
Text: "This message should be written to the disk.", Text: "This message should be written to the disk."
}, }
} }

View file

@ -6,7 +6,7 @@ expression: src/dir_with_meta/init.meta.json5
className: "Configuration", className: "Configuration",
properties: { properties: {
Tags: [ Tags: [
"This tag on dir_with_meta", "This tag on dir_with_meta"
], ]
}, }
} }

View file

@ -5,6 +5,6 @@ expression: src/model_json.model.json5
{ {
className: "StringValue", className: "StringValue",
properties: { properties: {
Value: "This text is model_json", Value: "This text is model_json"
}, }
} }

View file

@ -10,8 +10,8 @@ expression: src/project_json.project.json5
Value: [ Value: [
1337, 1337,
-1337, -1337,
1337, 1337
], ]
}, }
}, }
} }

View file

@ -9,11 +9,11 @@ expression: nested.project.json5
BoolValue: { BoolValue: {
$className: "BoolValue", $className: "BoolValue",
$properties: { $properties: {
Value: true, Value: true
}, }
}, },
StringValue: { StringValue: {
$path: "string_value.txt", $path: "string_value.txt"
}, }
}, }
} }

View file

@ -8,8 +8,8 @@ expression: property_mismatch.project.json5
$className: "BrickColorValue", $className: "BrickColorValue",
$properties: { $properties: {
Value: { Value: {
BrickColor: 345, BrickColor: 345
}, }
}, }
}, }
} }

View file

@ -4,7 +4,7 @@ expression: src/pointer.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Ref_Value: "ReplicatedStorage/target", Rojo_Ref_Value: "ReplicatedStorage/target"
}, },
className: "ObjectValue", className: "ObjectValue"
} }

View file

@ -4,7 +4,7 @@ expression: src/target.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Id: "test referent id", Rojo_Id: "test referent id"
}, },
className: "Folder", className: "Folder"
} }

View file

@ -4,7 +4,7 @@ expression: src/pointer.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Ref_Value: "ReplicatedStorage/target", Rojo_Ref_Value: "ReplicatedStorage/target"
}, },
className: "ObjectValue", className: "ObjectValue"
} }

View file

@ -4,7 +4,7 @@ expression: src/Pointer_2.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Ref_Value: "ReplicatedStorage/Target_2", Rojo_Ref_Value: "ReplicatedStorage/Target_2"
}, },
className: "ObjectValue", className: "ObjectValue"
} }

View file

@ -4,7 +4,7 @@ expression: src/Target_2.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Id: "identical ID", Rojo_Id: "identical ID"
}, },
className: "Folder", className: "Folder"
} }

View file

@ -4,7 +4,7 @@ expression: src/Pointer1.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Ref_Value: "ReplicatedStorage/Target1", Rojo_Ref_Value: "ReplicatedStorage/Target1"
}, },
className: "ObjectValue", className: "ObjectValue"
} }

View file

@ -4,7 +4,7 @@ expression: src/Pointer2.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Ref_Value: "ReplicatedStorage/Target2", Rojo_Ref_Value: "ReplicatedStorage/Target2"
}, },
className: "ObjectValue", className: "ObjectValue"
} }

View file

@ -4,7 +4,7 @@ expression: src/Pointer3.model.json5
--- ---
{ {
attributes: { attributes: {
Rojo_Ref_Value: "ReplicatedStorage/Target3", Rojo_Ref_Value: "ReplicatedStorage/Target3"
}, },
className: "ObjectValue", className: "ObjectValue"
} }

View file

@ -9,8 +9,8 @@ expression: default.project.json5
project_node: { project_node: {
$className: "BoolValue", $className: "BoolValue",
$properties: { $properties: {
Value: true, Value: true
}, }
}, }
}, }
} }

View file

@ -5,6 +5,6 @@ expression: src/model_json.model.json5
{ {
className: "StringValue", className: "StringValue",
properties: { properties: {
Value: "This should be a .model.json file", Value: "This should be a .model.json file"
}, }
} }

View file

@ -8,13 +8,13 @@ expression: default.project.json5
tree: { tree: {
$className: "Folder", $className: "Folder",
src: { src: {
$path: "src", $path: "src"
}, },
trigger_reserialization: { trigger_reserialization: {
$className: "BoolValue", $className: "BoolValue",
$properties: { $properties: {
Value: true, Value: true
}, }
}, }
}, }
} }

View file

@ -3,8 +3,7 @@ source: tests/tests/syncback.rs
expression: src/adjacent.meta.json5 expression: src/adjacent.meta.json5
--- ---
{ {
$schema: "rojo/adjacent-meta",
attributes: { attributes: {
trigger_reserialization: true, trigger_reserialization: true
}, }
} }

View file

@ -3,8 +3,7 @@ source: tests/tests/syncback.rs
expression: src/init/init.meta.json5 expression: src/init/init.meta.json5
--- ---
{ {
$schema: "rojo/init-meta",
attributes: { attributes: {
trigger_reserialization: true, trigger_reserialization: true
}, }
} }

View file

@ -3,6 +3,5 @@ source: tests/tests/syncback.rs
expression: src/model.model.json5 expression: src/model.model.json5
--- ---
{ {
$schema: "rojo/model.json", className: "BoolValue"
className: "BoolValue",
} }

View file

@ -9,14 +9,14 @@ expression: default.project.json5
inside_project_file: { inside_project_file: {
$className: "StringValue", $className: "StringValue",
$properties: { $properties: {
Value: "imgettingverytiredofwritingthesetests2", Value: "imgettingverytiredofwritingthesetests2"
}, }
}, },
on_file_system: { on_file_system: {
$attributes: { $attributes: {
imgettingverytiredofwritingthesetests: "person299 was ahead of his time", imgettingverytiredofwritingthesetests: "person299 was ahead of his time"
},
$path: "string_value.txt",
},
}, },
$path: "string_value.txt"
}
}
} }

View file

@ -5,14 +5,14 @@ expression: default.project.json5
{ {
name: "unscriptable_properties", name: "unscriptable_properties",
syncbackRules: { syncbackRules: {
syncUnscriptable: true, syncUnscriptable: true
}, },
tree: { tree: {
$className: "BinaryStringValue", $className: "BinaryStringValue",
$properties: { $properties: {
Value: { Value: {
BinaryString: "Rojo/is/cool", BinaryString: "Rojo/is/cool"
}, }
}, }
}, }
} }

View file

@ -0,0 +1,10 @@
{
"name": "clean_mode_orphan_removal",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"$path": "src"
}
}
}

View file

@ -0,0 +1,3 @@
-- This script should be KEPT because the input model
-- has a ModuleScript with the same name "KeptScript"
print("I should be kept")

View file

@ -0,0 +1,3 @@
-- This nested script should also be REMOVED
-- when its parent folder is removed
print("I am also orphaned")

View file

@ -0,0 +1,3 @@
-- This script should be REMOVED in clean mode
-- because the input model doesn't have this script
print("I am orphaned")

View file

@ -54,6 +54,12 @@ pub struct SyncbackCommand {
/// If provided, the prompt for writing to the file system is skipped. /// If provided, the prompt for writing to the file system is skipped.
#[clap(long, short = 'y')] #[clap(long, short = 'y')]
pub non_interactive: bool, pub non_interactive: bool,
/// If provided, syncback will preserve existing file structure and middleware
/// formats when possible. Without this flag (default), syncback creates a fresh
/// project layout that exactly matches the input file, removing any orphaned files.
#[clap(long, short = 'n')]
pub incremental: bool,
} }
impl SyncbackCommand { impl SyncbackCommand {
@ -99,12 +105,17 @@ impl SyncbackCommand {
} }
let syncback_timer = Instant::now(); let syncback_timer = Instant::now();
eprintln!("Beginning syncback..."); if self.incremental {
eprintln!("Beginning incremental syncback...");
} else {
eprintln!("Beginning syncback (clean mode)...");
}
let snapshot = syncback_loop( let snapshot = syncback_loop(
session_old.vfs(), session_old.vfs(),
&mut dom_old, &mut dom_old,
dom_new, dom_new,
session_old.root_project(), session_old.root_project(),
self.incremental,
)?; )?;
log::debug!( log::debug!(
"Syncback finished in {:.02}s!", "Syncback finished in {:.02}s!",

View file

@ -14,9 +14,9 @@ use rbx_dom_weak::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::{HashMap, VecDeque}, collections::{HashMap, HashSet, VecDeque},
env, env,
path::Path, path::{Path, PathBuf},
sync::OnceLock, sync::OnceLock,
}; };
@ -74,6 +74,7 @@ pub fn syncback_loop(
old_tree: &mut RojoTree, old_tree: &mut RojoTree,
mut new_tree: WeakDom, mut new_tree: WeakDom,
project: &Project, project: &Project,
incremental: bool,
) -> anyhow::Result<FsSnapshot> { ) -> anyhow::Result<FsSnapshot> {
let ignore_patterns = project let ignore_patterns = project
.syncback_rules .syncback_rules
@ -192,13 +193,40 @@ pub fn syncback_loop(
let project_path = project.folder_location(); let project_path = project.folder_location();
// In clean mode, collect all existing paths from old tree for cleanup.
// These will be compared against new paths to remove orphaned files.
let existing_paths: HashSet<PathBuf> = if !incremental {
log::debug!("Clean mode: collecting existing paths for cleanup");
let mut paths = HashSet::new();
for ref_id in descendants(old_tree.inner(), old_tree.get_root_id()) {
if let Some(inst) = old_tree.get_instance(ref_id) {
for path in inst.metadata().relevant_paths.iter() {
// Skip paths matching ignore patterns
if is_valid_path(&ignore_patterns, project_path, path) {
// Only include paths that actually exist on disk
if path.exists() {
paths.insert(path.clone());
}
}
}
}
}
log::debug!("Collected {} existing paths", paths.len());
paths
} else {
HashSet::new()
};
let syncback_data = SyncbackData { let syncback_data = SyncbackData {
vfs, vfs,
old_tree, old_tree,
new_tree: &new_tree, new_tree: &new_tree,
project, project,
incremental,
}; };
// Always start with old reference for the Project middleware.
// In clean mode, child snapshots will have old=None (handled by SyncbackSnapshot methods).
let mut snapshots = vec![SyncbackSnapshot { let mut snapshots = vec![SyncbackSnapshot {
data: syncback_data, data: syncback_data,
old: Some(old_tree.get_root_id()), old: Some(old_tree.get_root_id()),
@ -211,8 +239,10 @@ pub fn syncback_loop(
'syncback: while let Some(snapshot) = snapshots.pop() { 'syncback: while let Some(snapshot) = snapshots.pop() {
let inst_path = snapshot.get_new_inst_path(snapshot.new); let inst_path = snapshot.get_new_inst_path(snapshot.new);
// We can quickly check that two subtrees are identical and if they are, // In incremental mode, we can quickly check that two subtrees are identical
// skip reconciling them. // and if they are, skip reconciling them. In clean mode, we always process
// all instances to ensure fresh structure.
if incremental {
if let Some(old_ref) = snapshot.old { if let Some(old_ref) = snapshot.old {
match (old_hashes.get(&old_ref), new_hashes.get(&snapshot.new)) { match (old_hashes.get(&old_ref), new_hashes.get(&snapshot.new)) {
(Some(old), Some(new)) => { (Some(old), Some(new)) => {
@ -226,6 +256,7 @@ pub fn syncback_loop(
_ => unreachable!("All Instances in both DOMs should have hashes"), _ => unreachable!("All Instances in both DOMs should have hashes"),
} }
} }
}
if !is_valid_path(&ignore_patterns, project_path, &snapshot.path) { if !is_valid_path(&ignore_patterns, project_path, &snapshot.path) {
log::debug!("Skipping {inst_path} because its path matches ignore pattern"); log::debug!("Skipping {inst_path} because its path matches ignore pattern");
@ -331,6 +362,91 @@ pub fn syncback_loop(
snapshots.extend(syncback.children); snapshots.extend(syncback.children);
} }
// In clean mode, remove any existing paths that weren't overwritten by new structure.
// This ensures no orphaned files remain from the old project layout.
if !incremental && !existing_paths.is_empty() {
log::debug!("Clean mode: checking for orphaned files to remove");
// Helper to normalize a path by stripping UNC prefix on Windows
// This is needed because paths may have different prefixes (\\?\ vs regular)
fn normalize_path(p: &Path) -> PathBuf {
// Strip Windows UNC prefix if present
let path_str = p.to_string_lossy();
if path_str.starts_with(r"\\?\") || path_str.starts_with("//?/") {
PathBuf::from(&path_str[4..])
} else {
p.to_path_buf()
}
}
// Convert added_paths to absolute paths by joining with project base path.
// fs_snapshot paths are relative to the project folder.
let added_paths: HashSet<PathBuf> = fs_snapshot
.added_paths()
.into_iter()
.map(|p| normalize_path(&project_path.join(p)))
.collect();
// Get the project file path to exclude it from removal
let project_file = normalize_path(&project.file_location);
// First pass: collect normalized paths to remove (deduplicated)
let mut paths_to_remove: HashSet<PathBuf> = HashSet::new();
for old_path in &existing_paths {
let old_path_norm = normalize_path(old_path);
// Never remove the project file itself
if old_path_norm == project_file {
log::debug!("Skipping project file: {}", old_path.display());
continue;
}
// Skip if this exact path is being written to
if added_paths.contains(&old_path_norm) {
continue;
}
// Skip if a parent of this path is being added
// (e.g., the new structure will replace it)
if added_paths
.iter()
.any(|added| old_path_norm.starts_with(added))
{
continue;
}
// Skip if this path is an ancestor of something being written to
// (e.g., a directory that will contain new files)
if added_paths
.iter()
.any(|added| added.starts_with(&old_path_norm))
{
continue;
}
// Use normalized path to avoid duplicates from UNC vs regular paths
paths_to_remove.insert(old_path_norm);
}
// Second pass: only remove top-level orphaned paths.
// Skip any path that is inside another path we're removing,
// since remove_dir_all will handle the contents.
let paths_vec: Vec<_> = paths_to_remove.iter().collect();
for old_path in &paths_vec {
// Check if this path is inside another path we're removing
let is_nested = paths_vec
.iter()
.any(|other| *other != *old_path && old_path.starts_with(*other));
if is_nested {
continue;
}
log::debug!("Removing orphaned path: {}", old_path.display());
if old_path.is_dir() {
fs_snapshot.remove_dir(old_path);
} else {
fs_snapshot.remove_file(old_path);
}
}
}
Ok(fs_snapshot) Ok(fs_snapshot)
} }

View file

@ -21,6 +21,9 @@ pub struct SyncbackData<'sync> {
pub(super) old_tree: &'sync RojoTree, pub(super) old_tree: &'sync RojoTree,
pub(super) new_tree: &'sync WeakDom, pub(super) new_tree: &'sync WeakDom,
pub(super) project: &'sync Project, pub(super) project: &'sync Project,
/// When true, preserves existing file structure and middleware formats.
/// When false (clean mode), all child instances are treated as new.
pub(super) incremental: bool,
} }
pub struct SyncbackSnapshot<'sync> { pub struct SyncbackSnapshot<'sync> {
@ -35,11 +38,16 @@ impl<'sync> SyncbackSnapshot<'sync> {
/// Constructs a SyncbackSnapshot from the provided refs /// Constructs a SyncbackSnapshot from the provided refs
/// while inheriting this snapshot's path and data. This should be used for /// while inheriting this snapshot's path and data. This should be used for
/// directories. /// directories.
///
/// In clean mode (incremental=false), old_ref is ignored and set to None,
/// ensuring fresh filenames and middleware choices for all children.
#[inline] #[inline]
pub fn with_joined_path(&self, new_ref: Ref, old_ref: Option<Ref>) -> anyhow::Result<Self> { pub fn with_joined_path(&self, new_ref: Ref, old_ref: Option<Ref>) -> anyhow::Result<Self> {
// In clean mode, ignore old_ref to ensure fresh structure
let effective_old_ref = if self.data.incremental { old_ref } else { None };
let mut snapshot = Self { let mut snapshot = Self {
data: self.data, data: self.data,
old: old_ref, old: effective_old_ref,
new: new_ref, new: new_ref,
path: PathBuf::new(), path: PathBuf::new(),
middleware: None, middleware: None,
@ -62,6 +70,9 @@ impl<'sync> SyncbackSnapshot<'sync> {
/// ///
/// The actual path of the snapshot is made by getting a file name for the /// The actual path of the snapshot is made by getting a file name for the
/// snapshot and then appending it to the provided base path. /// snapshot and then appending it to the provided base path.
///
/// In clean mode (incremental=false), old_ref is ignored and set to None,
/// ensuring fresh filenames and middleware choices for all children.
#[inline] #[inline]
pub fn with_base_path( pub fn with_base_path(
&self, &self,
@ -69,9 +80,11 @@ impl<'sync> SyncbackSnapshot<'sync> {
new_ref: Ref, new_ref: Ref,
old_ref: Option<Ref>, old_ref: Option<Ref>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
// In clean mode, ignore old_ref to ensure fresh structure
let effective_old_ref = if self.data.incremental { old_ref } else { None };
let mut snapshot = Self { let mut snapshot = Self {
data: self.data, data: self.data,
old: old_ref, old: effective_old_ref,
new: new_ref, new: new_ref,
path: PathBuf::new(), path: PathBuf::new(),
middleware: None, middleware: None,

View file

@ -11,7 +11,7 @@ const INPUT_FILE_PROJECT: &str = "input-project";
const INPUT_FILE_PLACE: &str = "input.rbxl"; const INPUT_FILE_PLACE: &str = "input.rbxl";
const INPUT_FILE_MODEL: &str = "input.rbxm"; const INPUT_FILE_MODEL: &str = "input.rbxm";
/// Convenience method to run a `rojo syncback` test. /// Convenience method to run a `rojo syncback` test in clean mode (default).
/// ///
/// Test projects should be defined in the `syncback-tests` folder; their filename /// Test projects should be defined in the `syncback-tests` folder; their filename
/// should be given as the first parameter. /// should be given as the first parameter.
@ -19,6 +19,18 @@ const INPUT_FILE_MODEL: &str = "input.rbxm";
/// The passed in callback is where the actual test body should go. Setup and /// The passed in callback is where the actual test body should go. Setup and
/// cleanup happens automatically. /// cleanup happens automatically.
pub fn run_syncback_test(name: &str, callback: impl FnOnce(&Path)) { pub fn run_syncback_test(name: &str, callback: impl FnOnce(&Path)) {
run_syncback_test_impl(name, false, callback)
}
/// Convenience method to run a `rojo syncback` test in incremental mode.
///
/// Use this for tests that depend on preserving existing file structure and
/// middleware formats.
pub fn run_syncback_test_incremental(name: &str, callback: impl FnOnce(&Path)) {
run_syncback_test_impl(name, true, callback)
}
fn run_syncback_test_impl(name: &str, incremental: bool, callback: impl FnOnce(&Path)) {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
// let working_dir = get_working_dir_path(); // let working_dir = get_working_dir_path();
@ -55,12 +67,7 @@ pub fn run_syncback_test(name: &str, callback: impl FnOnce(&Path)) {
.expect("Couldn't copy project to temporary directory"); .expect("Couldn't copy project to temporary directory");
}; };
let output = Command::new(ROJO_PATH) let mut args = vec![
// I don't really understand why setting the working directory breaks this, but it does.
// It's a bit concerning but I'm more interested in writing tests than debugging it right now.
// TODO: Figure out why and fix it.
// .current_dir(working_dir)
.args([
"--color", "--color",
"never", "never",
"syncback", "syncback",
@ -69,7 +76,18 @@ pub fn run_syncback_test(name: &str, callback: impl FnOnce(&Path)) {
input_file.to_str().unwrap(), input_file.to_str().unwrap(),
"--non-interactive", "--non-interactive",
"--list", "--list",
]) ];
if incremental {
args.push("--incremental");
}
let output = Command::new(ROJO_PATH)
// I don't really understand why setting the working directory breaks this, but it does.
// It's a bit concerning but I'm more interested in writing tests than debugging it right now.
// TODO: Figure out why and fix it.
// .current_dir(working_dir)
.args(args)
.output() .output()
.expect("Couldn't spawn syncback process"); .expect("Couldn't spawn syncback process");

322
tests/tests/clean_mode.rs Normal file
View file

@ -0,0 +1,322 @@
//! Tests for clean mode (default) vs incremental mode behavior.
//!
//! These tests verify that:
//! 1. Clean mode removes orphaned files
//! 2. Clean mode doesn't remove files that are being written to
//! 3. Clean mode doesn't remove parent directories of files being written
//! 4. Incremental mode preserves existing file structure
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
use crate::rojo_test::io_util::{copy_recursive, ROJO_PATH};
const SYNCBACK_TESTS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/rojo-test/syncback-tests");
/// Helper to run syncback and return success status
fn run_syncback(project_path: &Path, input_path: &Path, incremental: bool) -> bool {
let mut args = vec![
"--color",
"never",
"syncback",
project_path.to_str().unwrap(),
"--input",
input_path.to_str().unwrap(),
"--non-interactive",
];
if incremental {
args.push("--incremental");
}
let output = Command::new(ROJO_PATH)
.args(args)
.output()
.expect("Couldn't spawn syncback process");
if !output.status.success() {
eprintln!("Syncback failed!");
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
}
output.status.success()
}
/// Test that clean mode removes orphaned files that don't exist in the input
#[test]
fn clean_mode_removes_orphaned_files() {
let _ = env_logger::try_init();
// Use the sync_rules test case and add an orphaned file
let source_path = Path::new(SYNCBACK_TESTS_PATH)
.join("sync_rules")
.join("input-project");
let input_file = Path::new(SYNCBACK_TESTS_PATH)
.join("sync_rules")
.join("input.rbxm");
let test_dir = tempdir().expect("Couldn't create temporary directory");
let project_path = test_dir.path().join("test_project");
fs_err::create_dir(&project_path).expect("Couldn't create project directory");
copy_recursive(&source_path, &project_path).expect("Couldn't copy project");
// Create an orphaned file that doesn't match anything in the input
let orphaned_file = project_path.join("src").join("OrphanedFile.luau");
fs_err::write(&orphaned_file, "-- This is orphaned").expect("Couldn't create orphaned file");
assert!(
orphaned_file.exists(),
"Orphaned file should exist before syncback"
);
// Run syncback in clean mode (default)
assert!(
run_syncback(&project_path, &input_file, false),
"Syncback should succeed"
);
// The orphaned file should be removed in clean mode
// (the input doesn't have "OrphanedFile" script)
assert!(
!orphaned_file.exists(),
"Orphaned file should be removed in clean mode"
);
// The expected files should exist
let module_file = project_path.join("src").join("module.luau");
assert!(
module_file.exists(),
"module.luau should be created by syncback"
);
}
/// Test that clean mode doesn't remove files that are being written to
#[test]
fn clean_mode_preserves_written_files() {
let _ = env_logger::try_init();
// Use the child_but_not test case
let source_path = Path::new(SYNCBACK_TESTS_PATH)
.join("child_but_not")
.join("input-project");
let input_file = Path::new(SYNCBACK_TESTS_PATH)
.join("child_but_not")
.join("input.rbxl");
let test_dir = tempdir().expect("Couldn't create temporary directory");
let project_path = test_dir.path().join("test_project");
fs_err::create_dir(&project_path).expect("Couldn't create project directory");
copy_recursive(&source_path, &project_path).expect("Couldn't copy project");
// Run syncback in clean mode
assert!(
run_syncback(&project_path, &input_file, false),
"Syncback should succeed"
);
// The directories being written to should exist
let only_one_copy = project_path.join("OnlyOneCopy");
let replicated_storage = project_path.join("ReplicatedStorage");
assert!(
only_one_copy.exists(),
"OnlyOneCopy directory should exist (it's being written to)"
);
assert!(
replicated_storage.exists(),
"ReplicatedStorage directory should exist (it's being written to)"
);
// The files inside should exist
assert!(
only_one_copy.join("child_of_one.luau").exists(),
"child_of_one.luau should exist"
);
assert!(
replicated_storage
.join("child_replicated_storage.luau")
.exists(),
"child_replicated_storage.luau should exist"
);
}
/// Test that incremental mode preserves existing file structure
#[test]
fn incremental_mode_preserves_structure() {
let _ = env_logger::try_init();
// Use the sync_rules test case - it has custom extensions
let source_path = Path::new(SYNCBACK_TESTS_PATH)
.join("sync_rules")
.join("input-project");
let input_file = Path::new(SYNCBACK_TESTS_PATH)
.join("sync_rules")
.join("input.rbxm");
let test_dir = tempdir().expect("Couldn't create temporary directory");
let project_path = test_dir.path().join("test_project");
fs_err::create_dir(&project_path).expect("Couldn't create project directory");
copy_recursive(&source_path, &project_path).expect("Couldn't copy project");
// Verify the custom-extension files exist before syncback
let modulescript = project_path.join("src").join("module.modulescript");
let text = project_path.join("src").join("text.text");
assert!(
modulescript.exists(),
"module.modulescript should exist before syncback"
);
assert!(text.exists(), "text.text should exist before syncback");
// Run syncback in incremental mode
assert!(
run_syncback(&project_path, &input_file, true),
"Syncback should succeed"
);
// In incremental mode, the custom extensions should be preserved
assert!(
modulescript.exists(),
"module.modulescript should be preserved in incremental mode"
);
assert!(
text.exists(),
"text.text should be preserved in incremental mode"
);
}
/// Test that clean mode uses fresh file extensions (not preserving old ones)
#[test]
fn clean_mode_uses_fresh_extensions() {
let _ = env_logger::try_init();
// Use the sync_rules test case - it has custom extensions
let source_path = Path::new(SYNCBACK_TESTS_PATH)
.join("sync_rules")
.join("input-project");
let input_file = Path::new(SYNCBACK_TESTS_PATH)
.join("sync_rules")
.join("input.rbxm");
let test_dir = tempdir().expect("Couldn't create temporary directory");
let project_path = test_dir.path().join("test_project");
fs_err::create_dir(&project_path).expect("Couldn't create project directory");
copy_recursive(&source_path, &project_path).expect("Couldn't copy project");
// Run syncback in clean mode (default)
assert!(
run_syncback(&project_path, &input_file, false),
"Syncback should succeed"
);
// In clean mode, the files should use standard extensions
let modulescript_old = project_path.join("src").join("module.modulescript");
let text_old = project_path.join("src").join("text.text");
let module_new = project_path.join("src").join("module.luau");
let text_new = project_path.join("src").join("text.txt");
// Old extensions should NOT exist
assert!(
!modulescript_old.exists(),
"module.modulescript should NOT exist in clean mode"
);
assert!(
!text_old.exists(),
"text.text should NOT exist in clean mode"
);
// New standard extensions SHOULD exist
assert!(
module_new.exists(),
"module.luau should exist in clean mode"
);
assert!(text_new.exists(), "text.txt should exist in clean mode");
}
/// Test that clean mode doesn't remove the project file
#[test]
fn clean_mode_preserves_project_file() {
let _ = env_logger::try_init();
let source_path = Path::new(SYNCBACK_TESTS_PATH)
.join("ignore_trees_adding")
.join("input-project");
let input_file = Path::new(SYNCBACK_TESTS_PATH)
.join("ignore_trees_adding")
.join("input.rbxl");
let test_dir = tempdir().expect("Couldn't create temporary directory");
let project_path = test_dir.path().join("test_project");
fs_err::create_dir(&project_path).expect("Couldn't create project directory");
copy_recursive(&source_path, &project_path).expect("Couldn't copy project");
let project_file = project_path.join("default.project.json5");
assert!(
project_file.exists(),
"Project file should exist before syncback"
);
// Run syncback in clean mode
assert!(
run_syncback(&project_path, &input_file, false),
"Syncback should succeed"
);
// Project file should still exist
assert!(
project_file.exists(),
"Project file should be preserved in clean mode"
);
}
/// Test that running clean mode twice produces identical results
#[test]
fn clean_mode_is_idempotent() {
let _ = env_logger::try_init();
let source_path = Path::new(SYNCBACK_TESTS_PATH)
.join("child_but_not")
.join("input-project");
let input_file = Path::new(SYNCBACK_TESTS_PATH)
.join("child_but_not")
.join("input.rbxl");
let test_dir = tempdir().expect("Couldn't create temporary directory");
let project_path = test_dir.path().join("test_project");
fs_err::create_dir(&project_path).expect("Couldn't create project directory");
copy_recursive(&source_path, &project_path).expect("Couldn't copy project");
// Run syncback twice in clean mode
assert!(
run_syncback(&project_path, &input_file, false),
"First syncback should succeed"
);
// Capture state after first run
let file1 = project_path.join("OnlyOneCopy").join("child_of_one.luau");
let content1 = fs_err::read_to_string(&file1).unwrap();
assert!(
run_syncback(&project_path, &input_file, false),
"Second syncback should succeed"
);
// File should still exist with same content
assert!(
file1.exists(),
"File should still exist after second syncback"
);
let content2 = fs_err::read_to_string(&file1).unwrap();
assert_eq!(
content1, content2,
"File content should be identical after second syncback"
);
}

View file

@ -1,3 +1,4 @@
mod build; mod build;
mod clean_mode;
mod serve; mod serve;
mod syncback; mod syncback;

View file

@ -2,7 +2,9 @@ use std::ffi::OsStr;
use insta::assert_snapshot; use insta::assert_snapshot;
use crate::rojo_test::syncback_util::{run_syncback_test, snapshot_rbxm}; use crate::rojo_test::syncback_util::{
run_syncback_test, run_syncback_test_incremental, snapshot_rbxm,
};
macro_rules! syncback_tests { macro_rules! syncback_tests {
($($test_name:ident => $list:expr$(,)?),*) => {$( ($($test_name:ident => $list:expr$(,)?),*) => {$(
@ -28,27 +30,47 @@ macro_rules! syncback_tests {
)*}; )*};
} }
/// Tests that need to run in incremental mode (preserving existing structure)
macro_rules! syncback_tests_incremental {
($($test_name:ident => $list:expr$(,)?),*) => {$(
#[test]
fn $test_name() {
run_syncback_test_incremental(stringify!($test_name), |path| {
for name in $list {
let snapshot_name = format!(concat!(stringify!($test_name), "-{}"), name);
let new = path.join::<&str>(name);
if !new.exists() {
panic!("the path stub '{}' does not exist after syncback runs. consider double checking for typos.", name);
}
if let Some("rbxm") = new.extension().and_then(OsStr::to_str) {
let content = fs_err::read(new).unwrap();
snapshot_rbxm(&snapshot_name, content, name);
} else {
let content = fs_err::read_to_string(new).unwrap();
assert_snapshot!(snapshot_name, content, name);
}
}
});
}
)*};
}
// Tests that run in clean mode (default)
syncback_tests! { syncback_tests! {
// Ensures that there's only one copy written to disk if navigating a // Ensures that there's only one copy written to disk if navigating a
// project file might yield two copies // project file might yield two copies
child_but_not => ["OnlyOneCopy/child_of_one.luau", "ReplicatedStorage/child_replicated_storage.luau"], child_but_not => ["OnlyOneCopy/child_of_one.luau", "ReplicatedStorage/child_replicated_storage.luau"],
// Ensures that syncback works with CSVs
csv => ["src/csv_init/init.csv", "src/csv.csv"],
// Ensures that if a RojoId is duplicated somewhere in the project, it's // Ensures that if a RojoId is duplicated somewhere in the project, it's
// rewritten rather than synced back as a conflict // rewritten rather than synced back as a conflict
duplicate_rojo_id => ["container.model.json5"], duplicate_rojo_id => ["container.model.json5"],
// Ensures that the `ignorePaths` setting works for additions (new files get .json5) // Ensures that the `ignorePaths` setting works for additions (new files get .json5)
ignore_paths_adding => ["src/int_value.model.json5", "src/subfolder/string_value.txt"], ignore_paths_adding => ["src/int_value.model.json5", "src/subfolder/string_value.txt"],
// Ensures that the `ignorePaths` setting works for `init` files
ignore_paths_init => ["src/non-init.luau", "src/init-file/init.luau"],
// Ensures that the `ignorePaths` setting works for removals (new file gets .json5) // Ensures that the `ignorePaths` setting works for removals (new file gets .json5)
ignore_paths_removing => ["src/Message.model.json5"], ignore_paths_removing => ["src/Message.model.json5"],
// Ensures that `ignoreTrees` works for additions // Ensures that `ignoreTrees` works for additions
ignore_trees_adding => [], ignore_trees_adding => [],
// Ensures that `ignoreTrees` works for removals // Ensures that `ignoreTrees` works for removals
ignore_trees_removing => [], ignore_trees_removing => [],
// Ensures that all of the JSON middlewares are handled as expected
json_middlewares => ["src/dir_with_meta/init.meta.json5", "src/model_json.model.json5", "src/project_json.project.json5"],
// Ensures projects that refer to other projects work as expected // Ensures projects that refer to other projects work as expected
nested_projects => ["nested.project.json5", "string_value.txt"], nested_projects => ["nested.project.json5", "string_value.txt"],
// Ensures files that are ignored by nested projects are picked up if // Ensures files that are ignored by nested projects are picked up if
@ -62,28 +84,37 @@ syncback_tests! {
project_reserialize => ["attribute_mismatch.luau", "property_mismatch.project.json5"], project_reserialize => ["attribute_mismatch.luau", "property_mismatch.project.json5"],
// Confirms that Instances that cannot serialize as directories serialize as rbxms // Confirms that Instances that cannot serialize as directories serialize as rbxms
rbxm_fallback => [], rbxm_fallback => [],
// Ensures that ref properties are linked properly on the file system
ref_properties => ["src/pointer.model.json5", "src/target.model.json5"],
// Ensures that ref properties are linked when no attributes are manually // Ensures that ref properties are linked when no attributes are manually
// set in the DataModel (new files get .json5) // set in the DataModel (new files get .json5)
ref_properties_blank => ["src/pointer.model.json5", "src/target.txt"], ref_properties_blank => ["src/pointer.model.json5", "src/target.txt"],
// Ensures that if there is a conflict in RojoRefs, one of them is rewritten
ref_properties_conflict => ["src/Pointer_2.model.json5", "src/Target_2.model.json5"],
// Ensures that having multiple pointers that are aimed at the same target doesn't trigger ref rewrites. // Ensures that having multiple pointers that are aimed at the same target doesn't trigger ref rewrites.
ref_properties_duplicate => [], ref_properties_duplicate => [],
// Ensures that ref properties that point to nothing after the prune both // Ensures that ref properties that point to nothing after the prune both
// do not leave any trace of themselves (new files get .json5) // do not leave any trace of themselves (new files get .json5)
ref_properties_pruned => ["src/Pointer1.model.json5", "src/Pointer2.model.json5", "src/Pointer3.model.json5"], ref_properties_pruned => ["src/Pointer1.model.json5", "src/Pointer2.model.json5", "src/Pointer3.model.json5"],
// Ensures that the old middleware is respected during syncback
respect_old_middleware => ["default.project.json5", "src/model_json.model.json5", "src/rbxm.rbxm", "src/rbxmx.rbxmx"],
// Ensures that the `$schema` field roundtrips with syncback // Ensures that the `$schema` field roundtrips with syncback
schema_roundtrip => ["default.project.json5", "src/model.model.json5", "src/init/init.meta.json5", "src/adjacent.meta.json5"], schema_roundtrip => ["default.project.json5", "src/model.model.json5", "src/init/init.meta.json5", "src/adjacent.meta.json5"],
// Ensures that StringValues inside project files are written to the // Ensures that StringValues inside project files are written to the
// project file, but only if they don't have `$path` set // project file, but only if they don't have `$path` set
string_value_project => ["default.project.json5"], string_value_project => ["default.project.json5"],
// Ensures that sync rules are respected. This is really just a test to
// ensure it uses the old path when possible, but we want the coverage.
sync_rules => ["src/module.modulescript", "src/text.text"],
// Ensures that the `syncUnscriptable` setting works // Ensures that the `syncUnscriptable` setting works
unscriptable_properties => ["default.project.json5"], unscriptable_properties => ["default.project.json5"],
} }
// Tests that run in incremental mode (preserving existing structure)
syncback_tests_incremental! {
// Ensures that syncback works with CSVs (preserves existing CSV structure)
csv => ["src/csv_init/init.csv", "src/csv.csv"],
// Ensures that the `ignorePaths` setting works for `init` files
ignore_paths_init => ["src/non-init.luau", "src/init-file/init.luau"],
// Ensures that all of the JSON middlewares are handled as expected
json_middlewares => ["src/dir_with_meta/init.meta.json5", "src/model_json.model.json5", "src/project_json.project.json5"],
// Ensures that ref properties are linked properly on the file system
ref_properties => ["src/pointer.model.json5", "src/target.model.json5"],
// Ensures that if there is a conflict in RojoRefs, one of them is rewritten
ref_properties_conflict => ["src/Pointer_2.model.json5", "src/Target_2.model.json5"],
// Ensures that the old middleware is respected during syncback (incremental mode only)
respect_old_middleware => ["default.project.json5", "src/model_json.model.json5", "src/rbxm.rbxm", "src/rbxmx.rbxmx"],
// Ensures that sync rules are respected (incremental mode only - uses old paths when possible)
sync_rules => ["src/module.modulescript", "src/text.text"],
}