Fix bugs in syncback process and enhance error handling

- Resolved issues with nested orphan file removal, ensuring that both top-level and nested orphan files are correctly handled.
- Improved directory removal logic to gracefully handle cases where directories may have already been removed, preventing crashes.
- Enhanced the path scanning algorithm to accurately collect all source directories defined in the project, addressing missing paths in the syncback process.
- Updated logging to provide clearer debug information during directory scanning and error handling in the syncback operations.
This commit is contained in:
Joe 2026-02-01 19:14:12 -06:00
parent 4a66eff938
commit fe1ac1dd95
5 changed files with 145 additions and 54 deletions

View file

@ -108,7 +108,18 @@ impl FsSnapshot {
lock.write(base_path.join(path), contents)?;
}
for dir_path in &self.removed_dirs {
lock.remove_dir_all(base_path.join(dir_path))?;
let full_path = base_path.join(dir_path);
match lock.remove_dir_all(&full_path) {
Ok(()) => (),
// Directory might have already been removed (e.g., added twice via different code paths)
Err(err) if err.kind() == io::ErrorKind::NotFound => {
log::debug!(
"Directory already removed or doesn't exist: {}",
full_path.display()
);
}
Err(err) => return Err(err),
}
}
// Only remove files that aren't already inside a directory we're removing.
// remove_dir_all already deleted those files recursively.

View file

@ -221,21 +221,66 @@ pub fn syncback_loop_with_stats(
// not just what Rojo loaded into its tree. This ensures orphaned files
// (like duplicates Rojo couldn't load) get cleaned up properly.
// Clean mode is essentially: "delete everything and rebuild from Studio"
eprintln!("[DEBUG] incremental = {}", incremental);
let existing_paths: HashSet<PathBuf> = if !incremental {
eprintln!("[DEBUG] Clean mode: scanning filesystem for existing paths");
log::debug!("Clean mode: scanning filesystem for existing paths");
let mut paths = HashSet::new();
// Get the source directories from the project's tree structure.
// We scan these directories recursively to find all files on disk.
// We need to collect ALL $path directories defined in the project,
// not just instigating_source metadata (which may point to project file).
let mut dirs_to_scan: Vec<PathBuf> = Vec::new();
// Helper to recursively collect $path entries from project tree
fn collect_paths_from_project(
node: &crate::project::ProjectNode,
base_path: &Path,
paths: &mut Vec<PathBuf>,
) {
// If this node has a $path, add it
if let Some(ref path_node) = node.path {
let resolved = base_path.join(path_node.path());
log::trace!("Found $path in project: {}", resolved.display());
if resolved.exists() && !paths.contains(&resolved) {
paths.push(resolved);
}
}
// Recursively check children
for child in node.children.values() {
collect_paths_from_project(child, base_path, paths);
}
}
// Collect paths from the project tree definition
collect_paths_from_project(&project.tree, project_path, &mut dirs_to_scan);
// Also check instance metadata for paths that might not be in project
// (e.g., from nested project references)
let root = old_tree.root();
log::trace!("Root instance: name={}, class={}", root.name(), root.class_name());
if let Some(source) = &root.metadata().instigating_source {
let path = source.path();
// Skip project files - we want source directories
if !path.to_string_lossy().ends_with(".project.json5")
&& !path.to_string_lossy().ends_with(".project.json")
{
log::trace!("Root has instigating_source: {}", path.display());
if path.exists() && !dirs_to_scan.contains(&path.to_path_buf()) {
dirs_to_scan.push(path.to_path_buf());
}
}
}
// Check children for additional paths
for ref_id in old_tree.inner().root().children() {
if let Some(inst) = old_tree.get_instance(*ref_id) {
if let Some(source) = &inst.metadata().instigating_source {
let path = source.path();
if path.exists() {
dirs_to_scan.push(path.to_path_buf());
if !path.to_string_lossy().ends_with(".project.json5")
&& !path.to_string_lossy().ends_with(".project.json")
{
if path.exists() && !dirs_to_scan.contains(&path.to_path_buf()) {
dirs_to_scan.push(path.to_path_buf());
}
}
}
}
@ -248,11 +293,11 @@ pub fn syncback_loop_with_stats(
ignore_patterns: &Option<Vec<Glob>>,
project_path: &Path,
) {
eprintln!("[DEBUG] scan_directory: {}", dir.display());
log::trace!("Scanning directory: {}", dir.display());
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("[DEBUG] Failed to read directory {}: {}", dir.display(), e);
log::trace!("Failed to read directory {}: {}", dir.display(), e);
return;
}
};
@ -262,19 +307,19 @@ pub fn syncback_loop_with_stats(
// Skip paths matching ignore patterns
if !is_valid_path(ignore_patterns, project_path, &path) {
eprintln!("[DEBUG] Skipping {} (matches ignore pattern)", path.display());
log::trace!("Skipping {} (matches ignore pattern)", path.display());
continue;
}
// Skip hidden files/directories (starting with .)
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
eprintln!("[DEBUG] Skipping {} (hidden)", path.display());
log::trace!("Skipping {} (hidden)", path.display());
continue;
}
}
eprintln!("[DEBUG] Found: {} (is_dir: {})", path.display(), path.is_dir());
log::trace!("Found path: {}", path.display());
paths.insert(path.clone());
if path.is_dir() {
@ -283,33 +328,23 @@ pub fn syncback_loop_with_stats(
}
}
eprintln!(
"[DEBUG] dirs_to_scan: {:?}",
log::trace!(
"dirs_to_scan: {:?}",
dirs_to_scan.iter().map(|p| p.display().to_string()).collect::<Vec<_>>()
);
for dir in &dirs_to_scan {
if dir.is_dir() {
eprintln!("[DEBUG] Scanning directory: {}", dir.display());
scan_directory(dir, &mut paths, &ignore_patterns, project_path);
} else if dir.exists() {
// It's a file, add it directly
eprintln!("[DEBUG] Adding file directly: {}", dir.display());
if is_valid_path(&ignore_patterns, project_path, dir) {
paths.insert(dir.clone());
}
} else {
eprintln!("[DEBUG] Path does not exist: {}", dir.display());
}
}
eprintln!(
"[DEBUG] Scanned {} existing paths from filesystem",
paths.len()
);
for p in &paths {
eprintln!("[DEBUG] - {}", p.display());
}
log::debug!("Scanned {} existing paths from filesystem", paths.len());
paths
} else {
HashSet::new()
@ -549,38 +584,34 @@ pub fn syncback_loop_with_stats(
// Never remove the project file itself
if old_path_norm == project_file {
log::debug!("Skipping project file: {}", old_path.display());
log::trace!("Skipping project file: {}", old_path.display());
continue;
}
// Never remove any nested project files - they define project structure
// and may be referenced via $path from other project files
if let Some(file_name) = old_path_norm.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".project.json5") || file_name.ends_with(".project.json") {
log::debug!("Skipping nested project file: {}", old_path.display());
log::trace!("Skipping nested 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))
{
log::trace!("Skipping {} (exact path in added_paths)", old_path.display());
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;
// NOTE: We DO NOT skip files just because their parent directory is in added_paths.
// Adding a directory does not mean all orphan files inside should be preserved.
if old_path_norm.is_dir() {
if added_paths.iter().any(|added| added.starts_with(&old_path_norm)) {
log::trace!("Skipping {} (ancestor of added path)", old_path.display());
continue;
}
}
// Use normalized path to avoid duplicates from UNC vs regular paths
log::trace!("Marking for removal: {}", old_path_norm.display());
paths_to_remove.insert(old_path_norm);
}