Fix syncback process to handle directory paths and class transitions in clean mode

- Resolved an issue where syncback failed for projects with `$path` pointing to single files by ensuring only directory paths are scanned for orphans.
- Implemented a function to create missing directories in clean mode, allowing restoration of deleted directories.
- Enhanced class transition handling in clean mode to allow recoverable transitions between Folder and Script types.
- Updated logging and error handling to improve clarity during the syncback process.
This commit is contained in:
Joe 2026-02-01 19:27:53 -06:00
parent fe1ac1dd95
commit 2e6f909a71
5 changed files with 194 additions and 27 deletions

View file

@ -27,6 +27,34 @@ use crate::{
use super::snapshot_from_vfs;
/// Checks if a class transition is recoverable in clean mode.
///
/// Recoverable transitions:
/// - Folder -> Script (creates init.luau)
/// - Script -> Folder (removes init.luau)
/// - Script -> Script (changes init.server/client.luau type)
fn can_transition_class(old_class: &str, new_class: &str) -> bool {
let is_script = |c: &str| matches!(c, "ModuleScript" | "Script" | "LocalScript");
let is_folder = |c: &str| c == "Folder";
// Folder can become a script (by creating init file)
if is_folder(old_class) && is_script(new_class) {
return true;
}
// Script can become Folder (by removing init file - handled by orphan removal)
if is_script(old_class) && is_folder(new_class) {
return true;
}
// Script can change type (e.g., ModuleScript -> LocalScript)
if is_script(old_class) && is_script(new_class) {
return true;
}
false
}
pub fn snapshot_project(
context: &InstanceContext,
vfs: &Vfs,
@ -344,14 +372,26 @@ pub fn syncback_project<'sync>(
while let Some((node, old_inst, new_inst)) = node_queue.pop_front() {
log::debug!("Processing node {}", old_inst.name());
if old_inst.class_name() != new_inst.class {
anyhow::bail!(
"Cannot change the class of {} in project file {}.\n\
Current class is {}, it is a {} in the input file.",
old_inst.name(),
project_path.display(),
old_inst.class_name(),
new_inst.class
);
// In clean mode, allow recoverable class transitions
// (e.g., Folder -> ModuleScript by creating init.luau)
let can_recover = can_transition_class(old_inst.class_name().as_str(), new_inst.class.as_str());
if !snapshot.data.is_incremental() && can_recover {
log::debug!(
"Clean mode: allowing class transition {} -> {} for {}",
old_inst.class_name(),
new_inst.class,
old_inst.name()
);
} else {
anyhow::bail!(
"Cannot change the class of {} in project file {}.\n\
Current class is {}, it is a {} in the input file.",
old_inst.name(),
project_path.display(),
old_inst.class_name(),
new_inst.class
);
}
}
// TODO handle meta.json5 files in this branch. Right now, we perform
@ -372,20 +412,65 @@ pub fn syncback_project<'sync>(
base_path.join(node_path)
};
let middleware = match Middleware::middleware_for_path(
let mut middleware = match Middleware::middleware_for_path(
snapshot.vfs(),
&project.sync_rules,
&full_path,
)? {
Some(middleware) => middleware,
// The only way this can happen at this point is if the path does
// not exist on the file system or there's no middleware for it.
None => anyhow::bail!(
"path does not exist or could not be turned into a file Rojo understands: {}",
full_path.display()
),
None => {
// Path doesn't exist on filesystem. In clean mode, we can
// determine the middleware from the new instance and let
// syncback create the necessary files/directories.
if !snapshot.data.is_incremental() {
// Determine middleware based on new instance class
let inferred_middleware = match new_inst.class.as_str() {
"ModuleScript" => Middleware::ModuleScriptDir,
"Script" => Middleware::ServerScriptDir,
"LocalScript" => Middleware::ClientScriptDir,
"Folder" => Middleware::Dir,
// For other classes, default to Dir
_ => Middleware::Dir,
};
log::debug!(
"Clean mode: path {} doesn't exist, inferring {:?} middleware for class {}",
full_path.display(),
inferred_middleware,
new_inst.class
);
inferred_middleware
} else {
anyhow::bail!(
"Rojo project referred to a file using $path that could not be turned \
into a Roblox Instance by Rojo.\n\
File $path: {}",
full_path.display()
)
}
}
};
// In clean mode, when the filesystem has a directory (Dir middleware)
// but the new instance is a script, we need to use the appropriate
// script-dir middleware to create the init file and restore the script.
if !snapshot.data.is_incremental() && middleware == Middleware::Dir {
let script_middleware = match new_inst.class.as_str() {
"ModuleScript" => Some(Middleware::ModuleScriptDir),
"Script" => Some(Middleware::ServerScriptDir),
"LocalScript" => Some(Middleware::ClientScriptDir),
_ => None,
};
if let Some(new_middleware) = script_middleware {
log::debug!(
"Clean mode: overriding Dir->{:?} middleware for {} (class {})",
new_middleware,
full_path.display(),
new_inst.class
);
middleware = new_middleware;
}
}
descendant_snapshots.push(
snapshot
.with_new_path(full_path.clone(), new_inst.referent(), Some(old_inst.id()))