Add comprehensive syncback roundtrip and clean mode stress tests

- Completed the implementation of roundtrip tests to validate the syncback process, ensuring that building a project, syncing back, and rebuilding produces semantically identical outputs.
- Created a new utility module for roundtrip testing, including functions for running builds and asserting directory equality.
- Developed clean mode stress tests to verify that syncing back to a dirty directory yields identical results to syncing back to a fresh directory, covering various mutation scenarios.
- Updated existing test modules to incorporate new test cases and ensure consistency in handling standalone scripts transitioning to directory format when children are added.
This commit is contained in:
Joe 2026-02-01 19:01:42 -06:00
parent 17d250f26a
commit 4a66eff938
10 changed files with 1517 additions and 50 deletions

View file

@ -110,8 +110,31 @@ impl FsSnapshot {
for dir_path in &self.removed_dirs {
lock.remove_dir_all(base_path.join(dir_path))?;
}
// Only remove files that aren't already inside a directory we're removing.
// remove_dir_all already deleted those files recursively.
// Also handle the case where the same file might be listed twice (absolute vs relative path)
// by gracefully handling "file not found" errors.
for path in &self.removed_files {
lock.remove_file(base_path.join(path))?;
let is_inside_removed_dir = self
.removed_dirs
.iter()
.any(|dir| path.starts_with(dir));
if is_inside_removed_dir {
continue;
}
let full_path = base_path.join(path);
match lock.remove_file(&full_path) {
Ok(()) => (),
// File might have already been removed (e.g., by remove_dir_all, or listed twice
// with different path formats like absolute vs relative)
Err(err) if err.kind() == io::ErrorKind::NotFound => {
log::debug!(
"File already removed or doesn't exist: {}",
full_path.display()
);
}
Err(err) => return Err(err),
}
}
drop(lock);
@ -120,10 +143,21 @@ impl FsSnapshot {
self.added_dirs.len(),
self.added_files.len()
);
// Count how many files were skipped because they're inside removed directories
let files_inside_dirs = self
.removed_files
.iter()
.filter(|path| {
self.removed_dirs
.iter()
.any(|dir| path.starts_with(dir))
})
.count();
log::debug!(
"Removed {} directories and {} files from the file system",
"Removed {} directories and {} files from the file system ({} files skipped, inside removed dirs)",
self.removed_dirs.len(),
self.removed_files.len()
self.removed_files.len() - files_inside_dirs,
files_inside_dirs
);
Ok(())
}

View file

@ -221,7 +221,9 @@ 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();
@ -246,9 +248,13 @@ pub fn syncback_loop_with_stats(
ignore_patterns: &Option<Vec<Glob>>,
project_path: &Path,
) {
eprintln!("[DEBUG] scan_directory: {}", dir.display());
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
Err(e) => {
eprintln!("[DEBUG] Failed to read directory {}: {}", dir.display(), e);
return;
}
};
for entry in entries.flatten() {
@ -256,16 +262,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());
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());
continue;
}
}
eprintln!("[DEBUG] Found: {} (is_dir: {})", path.display(), path.is_dir());
paths.insert(path.clone());
if path.is_dir() {
@ -274,18 +283,33 @@ pub fn syncback_loop_with_stats(
}
}
eprintln!(
"[DEBUG] 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());
}
}
log::debug!("Scanned {} existing paths from filesystem", paths.len());
eprintln!(
"[DEBUG] Scanned {} existing paths from filesystem",
paths.len()
);
for p in &paths {
eprintln!("[DEBUG] - {}", p.display());
}
paths
} else {
HashSet::new()