mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
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:
parent
0e5e809894
commit
58399bb863
39 changed files with 652 additions and 119 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,6 @@ expression: src/int_value.model.json5
|
||||||
{
|
{
|
||||||
className: "IntValue",
|
className: "IntValue",
|
||||||
properties: {
|
properties: {
|
||||||
Value: 1337,
|
Value: 1337
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
],
|
]
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ expression: src/project_json.project.json5
|
||||||
Value: [
|
Value: [
|
||||||
1337,
|
1337,
|
||||||
-1337,
|
-1337,
|
||||||
1337,
|
1337
|
||||||
],
|
]
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ expression: property_mismatch.project.json5
|
||||||
$className: "BrickColorValue",
|
$className: "BrickColorValue",
|
||||||
$properties: {
|
$properties: {
|
||||||
Value: {
|
Value: {
|
||||||
BrickColor: 345,
|
BrickColor: 345
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ expression: src/Target_2.model.json5
|
||||||
---
|
---
|
||||||
{
|
{
|
||||||
attributes: {
|
attributes: {
|
||||||
Rojo_Id: "identical ID",
|
Rojo_Id: "identical ID"
|
||||||
},
|
},
|
||||||
className: "Folder",
|
className: "Folder"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ expression: default.project.json5
|
||||||
project_node: {
|
project_node: {
|
||||||
$className: "BoolValue",
|
$className: "BoolValue",
|
||||||
$properties: {
|
$properties: {
|
||||||
Value: true,
|
Value: true
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "clean_mode_orphan_removal",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"ReplicatedStorage": {
|
||||||
|
"$className": "ReplicatedStorage",
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- This nested script should also be REMOVED
|
||||||
|
-- when its parent folder is removed
|
||||||
|
print("I am also orphaned")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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!",
|
||||||
|
|
|
||||||
|
|
@ -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,19 +239,22 @@ 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
|
||||||
if let Some(old_ref) = snapshot.old {
|
// all instances to ensure fresh structure.
|
||||||
match (old_hashes.get(&old_ref), new_hashes.get(&snapshot.new)) {
|
if incremental {
|
||||||
(Some(old), Some(new)) => {
|
if let Some(old_ref) = snapshot.old {
|
||||||
if old == new {
|
match (old_hashes.get(&old_ref), new_hashes.get(&snapshot.new)) {
|
||||||
log::trace!(
|
(Some(old), Some(new)) => {
|
||||||
"Skipping {inst_path} due to it being identically hashed as {old:?}"
|
if old == new {
|
||||||
);
|
log::trace!(
|
||||||
continue;
|
"Skipping {inst_path} due to it being identically hashed as {old:?}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
_ => unreachable!("All Instances in both DOMs should have hashes"),
|
||||||
}
|
}
|
||||||
_ => unreachable!("All Instances in both DOMs should have hashes"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,21 +67,27 @@ 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 mut args = vec![
|
||||||
|
"--color",
|
||||||
|
"never",
|
||||||
|
"syncback",
|
||||||
|
project_path.to_str().unwrap(),
|
||||||
|
"--input",
|
||||||
|
input_file.to_str().unwrap(),
|
||||||
|
"--non-interactive",
|
||||||
|
"--list",
|
||||||
|
];
|
||||||
|
|
||||||
|
if incremental {
|
||||||
|
args.push("--incremental");
|
||||||
|
}
|
||||||
|
|
||||||
let output = Command::new(ROJO_PATH)
|
let output = Command::new(ROJO_PATH)
|
||||||
// I don't really understand why setting the working directory breaks this, but it does.
|
// 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.
|
// 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.
|
// TODO: Figure out why and fix it.
|
||||||
// .current_dir(working_dir)
|
// .current_dir(working_dir)
|
||||||
.args([
|
.args(args)
|
||||||
"--color",
|
|
||||||
"never",
|
|
||||||
"syncback",
|
|
||||||
project_path.to_str().unwrap(),
|
|
||||||
"--input",
|
|
||||||
input_file.to_str().unwrap(),
|
|
||||||
"--non-interactive",
|
|
||||||
"--list",
|
|
||||||
])
|
|
||||||
.output()
|
.output()
|
||||||
.expect("Couldn't spawn syncback process");
|
.expect("Couldn't spawn syncback process");
|
||||||
|
|
||||||
|
|
|
||||||
322
tests/tests/clean_mode.rs
Normal file
322
tests/tests/clean_mode.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
mod build;
|
mod build;
|
||||||
|
mod clean_mode;
|
||||||
mod serve;
|
mod serve;
|
||||||
mod syncback;
|
mod syncback;
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue