diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e44aed9..8be9b575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,18 @@ Making a new release? Simply add the new header with the version and date undern ## Unreleased +* Add VFS recording mode and non-recursive watch support to reduce OS file-watch handles during `atlas serve` startup on large projects +* Enhance scripts-only mode to filter non-script files at the snapshot middleware, VFS event, and prefetch layers +* Set `core.longpaths` git config during repository initialization for Windows long-path support + +
+Full commit log + +- `2ea1d493` Enhance VFS watch functionality and introduce recording mode +- `bfb5815f` Update TODO and enhance git configuration + +
+ ## [8.5.2] (March 3rd, 2026) * Fix `atlas init` to commit project files before adding agent submodules, producing cleaner git history diff --git a/crates/memofs/src/in_memory_fs.rs b/crates/memofs/src/in_memory_fs.rs index 9ac1041d..2ce1c658 100644 --- a/crates/memofs/src/in_memory_fs.rs +++ b/crates/memofs/src/in_memory_fs.rs @@ -241,7 +241,7 @@ impl VfsBackend for InMemoryFs { inner.event_receiver.clone() } - fn watch(&mut self, _path: &Path) -> io::Result<()> { + fn watch(&mut self, _path: &Path, _recursive: bool) -> io::Result<()> { Ok(()) } diff --git a/crates/memofs/src/lib.rs b/crates/memofs/src/lib.rs index 3109d235..12ecd08e 100644 --- a/crates/memofs/src/lib.rs +++ b/crates/memofs/src/lib.rs @@ -22,7 +22,7 @@ mod noop_backend; mod snapshot; mod std_backend; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::{io, str}; @@ -94,7 +94,7 @@ pub trait VfsBackend: sealed::Sealed + Send + 'static { fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>; fn event_receiver(&self) -> crossbeam_channel::Receiver; - fn watch(&mut self, path: &Path) -> io::Result<()>; + fn watch(&mut self, path: &Path, recursive: bool) -> io::Result<()>; fn unwatch(&mut self, path: &Path) -> io::Result<()>; } @@ -159,17 +159,28 @@ pub enum VfsEvent { struct VfsInner { backend: Box, watch_enabled: bool, + watch_recursive: bool, prefetch_cache: Option, + recorded_watch_paths: Option>, } impl VfsInner { + fn watch_or_record(&mut self, path: &Path) -> io::Result<()> { + if let Some(ref mut recorded) = self.recorded_watch_paths { + recorded.insert(path.to_path_buf()); + Ok(()) + } else { + self.backend.watch(path, self.watch_recursive) + } + } + /// Read raw bytes from the prefetch cache or the backend. /// Removes the entry from the cache on hit to free memory. fn read_raw(&mut self, path: &Path) -> io::Result> { if let Some(cache) = &mut self.prefetch_cache { if let Some(contents) = cache.files.remove(path) { - if self.watch_enabled { - self.backend.watch(path)?; + if self.watch_enabled && self.watch_recursive { + self.watch_or_record(path)?; } return Ok(contents); } @@ -177,8 +188,8 @@ impl VfsInner { let contents = self.backend.read(path)?; - if self.watch_enabled { - self.backend.watch(path)?; + if self.watch_enabled && self.watch_recursive { + self.watch_or_record(path)?; } Ok(contents) @@ -220,7 +231,7 @@ impl VfsInner { if let Some(cache) = &mut self.prefetch_cache { if let Some(child_paths) = cache.children.remove(path) { if self.watch_enabled { - self.backend.watch(path)?; + self.watch_or_record(path)?; } let inner = child_paths.into_iter().map(|p| Ok(DirEntry { path: p })); return Ok(ReadDir { @@ -232,7 +243,7 @@ impl VfsInner { let dir = self.backend.read_dir(path)?; if self.watch_enabled { - self.backend.watch(path)?; + self.watch_or_record(path)?; } Ok(dir) @@ -345,7 +356,9 @@ impl Vfs { let lock = VfsInner { backend: Box::new(backend), watch_enabled: true, + watch_recursive: true, prefetch_cache: None, + recorded_watch_paths: None, }; Self { @@ -388,6 +401,36 @@ impl Vfs { inner.watch_enabled = enabled; } + /// Sets whether new watches use recursive or non-recursive mode. + /// + /// When false, each `backend.watch()` call only watches the specific + /// directory -- not its children. This allows selective watching where + /// only directories actually traversed during snapshot get OS handles. + pub fn set_watch_recursive(&self, recursive: bool) { + let mut inner = self.inner.lock().unwrap(); + inner.watch_recursive = recursive; + } + + /// Begin recording mode: `read`/`read_dir` calls record touched paths + /// into an internal set instead of calling `backend.watch()`. + pub fn start_watch_recording(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.recorded_watch_paths = Some(HashSet::new()); + } + + /// End recording mode and return the set of paths that were touched. + pub fn take_recorded_paths(&self) -> Option> { + let mut inner = self.inner.lock().unwrap(); + inner.recorded_watch_paths.take() + } + + /// Explicitly watch a path using the current `watch_recursive` setting. + pub fn watch>(&self, path: P) -> io::Result<()> { + let mut inner = self.inner.lock().unwrap(); + let recursive = inner.watch_recursive; + inner.backend.watch(path.as_ref(), recursive) + } + /// Read a file from the VFS, or the underlying backend if it isn't /// resident. /// @@ -767,6 +810,50 @@ mod test { assert_eq!(vfs.read("test").unwrap().as_slice(), b"data"); } + #[test] + fn recording_mode_collects_read_dir_paths() { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/root", + VfsSnapshot::dir(HashMap::from([ + ("child.txt", VfsSnapshot::file("hello")), + ( + "subdir", + VfsSnapshot::dir(HashMap::from([("nested.txt", VfsSnapshot::file("world"))])), + ), + ])), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + vfs.start_watch_recording(); + + let _ = vfs.read_dir("/root").unwrap(); + let _ = vfs.read_dir("/root/subdir").unwrap(); + let _ = vfs.read("/root/child.txt").unwrap(); + + let recorded = vfs.take_recorded_paths().unwrap(); + assert!(recorded.contains(&PathBuf::from("/root"))); + assert!(recorded.contains(&PathBuf::from("/root/subdir"))); + assert!(recorded.contains(&PathBuf::from("/root/child.txt"))); + } + + #[test] + fn recording_mode_returns_none_when_not_started() { + let imfs = InMemoryFs::new(); + let vfs = Vfs::new(imfs); + assert!(vfs.take_recorded_paths().is_none()); + } + + #[test] + fn recording_mode_cleared_after_take() { + let imfs = InMemoryFs::new(); + let vfs = Vfs::new(imfs); + vfs.start_watch_recording(); + let _ = vfs.take_recorded_paths(); + assert!(vfs.take_recorded_paths().is_none()); + } + #[test] fn prefetch_cache_watch_registered_on_cache_hit() { let contents = "hello world".to_string(); @@ -779,7 +866,6 @@ mod test { cache_files.insert(file_path.clone(), contents.as_bytes().to_vec()); vfs.set_prefetch_cache(PrefetchCache { files: cache_files, - canonical: HashMap::new(), is_file: HashMap::new(), children: HashMap::new(), }); @@ -825,7 +911,6 @@ mod test { let vfs = Arc::new(Vfs::new(StdBackend::new_for_testing())); vfs.set_prefetch_cache(PrefetchCache { files: cache_files, - canonical: HashMap::new(), is_file: HashMap::new(), children: HashMap::new(), }); diff --git a/crates/memofs/src/noop_backend.rs b/crates/memofs/src/noop_backend.rs index 98b86d1a..90ac01c3 100644 --- a/crates/memofs/src/noop_backend.rs +++ b/crates/memofs/src/noop_backend.rs @@ -54,7 +54,7 @@ impl VfsBackend for NoopBackend { crossbeam_channel::never() } - fn watch(&mut self, _path: &Path) -> io::Result<()> { + fn watch(&mut self, _path: &Path, _recursive: bool) -> io::Result<()> { Err(io::Error::other("NoopBackend doesn't do anything")) } diff --git a/crates/memofs/src/std_backend.rs b/crates/memofs/src/std_backend.rs index 49f55539..f3c2c145 100644 --- a/crates/memofs/src/std_backend.rs +++ b/crates/memofs/src/std_backend.rs @@ -58,6 +58,7 @@ pub struct StdBackend { debouncer: Debouncer, watcher_receiver: Receiver, watches: HashSet, + recursive_watches: HashSet, /// Receiver for critical errors from the watcher thread. /// Callers should poll this to detect when watching becomes unreliable. critical_error_receiver: Receiver, @@ -113,6 +114,7 @@ impl StdBackend { debouncer, watcher_receiver: event_rx, watches: HashSet::new(), + recursive_watches: HashSet::new(), critical_error_receiver: error_rx, } } @@ -328,26 +330,47 @@ impl VfsBackend for StdBackend { self.watcher_receiver.clone() } - fn watch(&mut self, path: &Path) -> io::Result<()> { - if self.watches.contains(path) - || path - .ancestors() - .any(|ancestor| self.watches.contains(ancestor)) + fn watch(&mut self, path: &Path, recursive: bool) -> io::Result<()> { + if self.watches.contains(path) { + if !recursive || self.recursive_watches.contains(path) { + return Ok(()); + } + // Upgrade: path was watched non-recursively, now needs recursive. + // Fall through to re-issue the watch with RecursiveMode::Recursive. + } + if path + .ancestors() + .skip(1) + .any(|ancestor| self.recursive_watches.contains(ancestor)) { - Ok(()) + return Ok(()); + } + + let mode = if recursive { + RecursiveMode::Recursive } else { - // Only add to watches AFTER the watch succeeds - // This prevents a failed watch from permanently marking the path as "watched" - match self.debouncer.watch(path, RecursiveMode::Recursive) { - Ok(()) => { - log::debug!("Watching path: {}", path.display()); - self.watches.insert(path.to_path_buf()); - Ok(()) - } - Err(err) => { - log::warn!("Failed to watch path {}: {:?}", path.display(), err); - Err(io::Error::other(format!("{:?}", err))) + RecursiveMode::NonRecursive + }; + match self.debouncer.watch(path, mode) { + Ok(()) => { + log::debug!( + "Watching path ({}): {}", + if recursive { + "recursive" + } else { + "non-recursive" + }, + path.display() + ); + self.watches.insert(path.to_path_buf()); + if recursive { + self.recursive_watches.insert(path.to_path_buf()); } + Ok(()) + } + Err(err) => { + log::warn!("Failed to watch path {}: {:?}", path.display(), err); + Err(io::Error::other(format!("{:?}", err))) } } } @@ -366,6 +389,7 @@ impl VfsBackend for StdBackend { ); } self.watches.remove(path); + self.recursive_watches.remove(path); Ok(()) } Err(err) => { @@ -382,6 +406,7 @@ impl VfsBackend for StdBackend { path.display() ); self.watches.remove(path); + self.recursive_watches.remove(path); Ok(()) } else { log::warn!("Failed to unwatch path {}: {:?}", path.display(), err); @@ -414,17 +439,17 @@ mod tests { let mut backend = StdBackend::new_for_testing(); // Watch should succeed and add to internal set - assert!(backend.watch(&file_path).is_ok()); + assert!(backend.watch(&file_path, true).is_ok()); // Watching again should be a no-op (already watched) - assert!(backend.watch(&file_path).is_ok()); + assert!(backend.watch(&file_path, true).is_ok()); // Watch a non-existent path - behavior varies by platform // On some systems, watching a non-existent file might succeed // because notify watches the parent directory. We just verify // that calling watch doesn't panic or corrupt state. let nonexistent = dir.path().join("nonexistent.txt"); - let _ = backend.watch(&nonexistent); // Result varies by platform + let _ = backend.watch(&nonexistent, true); // Result varies by platform } #[test] @@ -451,13 +476,13 @@ mod tests { let mut backend = StdBackend::new_for_testing(); // Watch the file - assert!(backend.watch(&file_path).is_ok()); + assert!(backend.watch(&file_path, true).is_ok()); // Unwatch should succeed assert!(backend.unwatch(&file_path).is_ok()); // Should be able to watch again after unwatch - assert!(backend.watch(&file_path).is_ok()); + assert!(backend.watch(&file_path, true).is_ok()); } #[test] @@ -471,10 +496,43 @@ mod tests { let mut backend = StdBackend::new_for_testing(); // Watch the parent directory - assert!(backend.watch(&subdir).is_ok()); + assert!(backend.watch(&subdir, true).is_ok()); // Watching a file inside should be a no-op (covered by parent) - assert!(backend.watch(&file_path).is_ok()); + assert!(backend.watch(&file_path, true).is_ok()); + } + + #[test] + fn non_recursive_watch_upgrades_to_recursive() { + let dir = tempdir().unwrap(); + let subdir = dir.path().join("subdir"); + fs_err::create_dir(&subdir).unwrap(); + let child = subdir.join("child.txt"); + fs_err::write(&child, "content").unwrap(); + + let mut backend = StdBackend::new_for_testing(); + + // Watch non-recursively first + assert!(backend.watch(&subdir, false).is_ok()); + assert!(backend.watches.contains(subdir.as_path())); + assert!(!backend.recursive_watches.contains(subdir.as_path())); + + // Child should NOT be covered (parent is non-recursive) + assert!(!subdir + .ancestors() + .skip(1) + .any(|a| backend.recursive_watches.contains(a))); + + // Upgrade to recursive + assert!(backend.watch(&subdir, true).is_ok()); + assert!(backend.recursive_watches.contains(subdir.as_path())); + + // Now a child path should be recognized as covered by the recursive ancestor + assert!(child + .ancestors() + .skip(1) + .any(|a| backend.recursive_watches.contains(a))); + assert!(backend.watch(&child, true).is_ok()); } #[test] @@ -504,12 +562,12 @@ mod tests { // Rapidly cycle watch/unwatch to stress test state consistency for _ in 0..10 { - assert!(backend.watch(&file_path).is_ok()); + assert!(backend.watch(&file_path, true).is_ok()); assert!(backend.unwatch(&file_path).is_ok()); } // Final watch should still work - assert!(backend.watch(&file_path).is_ok()); + assert!(backend.watch(&file_path, true).is_ok()); } #[test] @@ -522,7 +580,7 @@ mod tests { let event_rx = backend.event_receiver(); // Watch the directory (more reliable than watching the file directly) - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); // Give the watcher time to start std::thread::sleep(Duration::from_millis(100)); @@ -597,7 +655,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Rapid modifications - 20 writes in quick succession @@ -635,7 +693,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // 10 cycles of create/delete @@ -670,7 +728,7 @@ mod tests { let dir = tempdir().unwrap(); let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Create 20 files rapidly @@ -722,7 +780,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Rename the file @@ -758,7 +816,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Chain of renames @@ -781,7 +839,7 @@ mod tests { let dir = tempdir().unwrap(); let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Create nested structure @@ -826,7 +884,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // 100 writes with no delay @@ -863,7 +921,7 @@ mod tests { let dir = tempdir().unwrap(); let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); let files: Vec<_> = (0..5) @@ -915,7 +973,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Simulate multiple undo/redo cycles @@ -969,7 +1027,7 @@ mod tests { let dir = tempdir().unwrap(); let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Rapidly create subdirectories with files @@ -1005,7 +1063,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); // Create empty file @@ -1041,7 +1099,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); fs_err::write(&file_path, "-- long name test").unwrap(); @@ -1067,7 +1125,7 @@ mod tests { let mut backend = StdBackend::new_for_testing(); let event_rx = backend.event_receiver(); - assert!(backend.watch(dir.path()).is_ok()); + assert!(backend.watch(dir.path(), true).is_ok()); std::thread::sleep(Duration::from_millis(100)); for name in &special_files { diff --git a/src/change_processor.rs b/src/change_processor.rs index fbe41b20..4d3b05db 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -16,7 +16,7 @@ use crate::{ snapshot::{ apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree, }, - snapshot_middleware::{snapshot_from_vfs, snapshot_project_node}, + snapshot_middleware::{is_script_relevant_path, snapshot_from_vfs, snapshot_project_node}, syncback::{ dedup_suffix::{compute_cleanup_action, parse_dedup_suffix, DedupCleanupAction}, deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix, @@ -92,6 +92,7 @@ impl ChangeProcessor { project_file_path: PathBuf, critical_error_receiver: Option>, git_repo_root: Option, + sync_scripts_only: bool, ) -> Self { let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1); let vfs_receiver = vfs.event_receiver(); @@ -109,6 +110,7 @@ impl ChangeProcessor { project_file_path, ref_path_index, git_repo_root, + sync_scripts_only, }; let job_thread = jod_thread::Builder::new() @@ -268,6 +270,9 @@ struct JobThreadContext { /// Git repository root, if the project is in a git repo. /// Used for auto-staging Source writes. git_repo_root: Option, + + /// When true, only script-related VFS events are processed. + sync_scripts_only: bool, } impl JobThreadContext { @@ -723,6 +728,24 @@ impl JobThreadContext { } } + if self.sync_scripts_only { + if let Some(ref path) = event_path { + if path.is_file() { + let not_in_tree = { + let tree = self.tree.lock().unwrap(); + tree.get_ids_at_path(path).is_empty() + }; + if not_in_tree && !is_script_relevant_path(path) { + log::trace!( + "VFS event SKIPPED (scripts-only, non-script file): {}", + self.display_path(path) + ); + return Vec::new(); + } + } + } + } + // For a given VFS event, we might have many changes to different parts // of the tree. Calculate and apply all of these changes. let applied_patches = match event { @@ -890,7 +913,8 @@ impl JobThreadContext { use crate::snapshot::InstanceContext; let start = Instant::now(); - let instance_context = InstanceContext::new(); + let mut instance_context = InstanceContext::new(); + instance_context.sync_scripts_only = self.sync_scripts_only; let snapshot = match snapshot_from_vfs(&instance_context, &self.vfs, &self.project_file_path) { diff --git a/src/serve_session.rs b/src/serve_session.rs index 1ef4628d..60306ad0 100644 --- a/src/serve_session.rs +++ b/src/serve_session.rs @@ -19,7 +19,7 @@ use crate::{ apply_patch_set, compute_patch_set, AppliedPatchSet, InstanceContext, InstanceSnapshot, PatchSet, RojoTree, }, - snapshot_middleware::snapshot_from_vfs, + snapshot_middleware::{is_script_relevant_path, snapshot_from_vfs}, }; /// Set to `true` to validate on plugin connect (useful for testing, do not enable on production). @@ -131,7 +131,7 @@ pub struct ServeSession { /// Collect all filesystem paths reachable from the project tree's `$path` /// entries, then read file contents in parallel. -fn prefetch_project_files(project: &Project) -> io::Result { +fn prefetch_project_files(project: &Project, sync_scripts_only: bool) -> io::Result { use rayon::prelude::*; use std::collections::HashMap; use walkdir::WalkDir; @@ -200,6 +200,7 @@ fn prefetch_project_files(project: &Project) -> io::Result { let file_data: Vec<_> = entries .par_iter() .filter(|e| e.file_type().is_file()) + .filter(|e| !sync_scripts_only || is_script_relevant_path(e.path())) .filter_map(|e| { let path = e.path().to_path_buf(); std::fs::read(&path).ok().map(|c| (path, c)) @@ -266,10 +267,11 @@ impl ServeSession { log::trace!("Starting new ServeSession at path {}", start_path.display()); let root_project = Project::load_initial_project(vfs, start_path)?; + let sync_scripts_only = root_project.sync_scripts_only.unwrap_or(false); if std::env::var("ATLAS_SEQUENTIAL").is_err() { let prefetch_start = Instant::now(); - match prefetch_project_files(&root_project) { + match prefetch_project_files(&root_project, sync_scripts_only) { Ok(cache) => { log::debug!( "Prefetch total: {} files in {:.1?}", @@ -286,7 +288,25 @@ impl ServeSession { let mut tree = RojoTree::new(InstanceSnapshot::new()); let root_id = tree.get_root_id(); - let instance_context = InstanceContext::new(); + let mut instance_context = InstanceContext::new(); + instance_context.sync_scripts_only = sync_scripts_only; + + let watch_start = Instant::now(); + let mut path_roots = Vec::new(); + collect_path_roots( + &root_project.tree, + root_project.folder_location(), + &mut path_roots, + ); + let _ = vfs.watch(root_project.folder_location()); + for root in &path_roots { + let _ = vfs.watch(root); + } + log::debug!( + "Set up {} recursive watches in {:.1?}", + path_roots.len() + 1, + watch_start.elapsed(), + ); let snap_start = Instant::now(); log::trace!("Generating snapshot of instances from VFS"); @@ -356,6 +376,7 @@ impl ServeSession { root_project.file_location.clone(), critical_error_receiver, git_repo_root.clone(), + root_project.sync_scripts_only.unwrap_or(false), ); Ok(Self { @@ -526,7 +547,8 @@ impl ServeSession { pub fn check_tree_freshness(&self) -> TreeFreshnessReport { let start = Instant::now(); let start_path: &Path = &self.root_project.file_location; - let instance_context = InstanceContext::new(); + let mut instance_context = InstanceContext::new(); + instance_context.sync_scripts_only = self.sync_scripts_only(); let snapshot = match snapshot_from_vfs(&instance_context, &self.vfs, start_path) { Ok(s) => s, @@ -589,7 +611,8 @@ impl ServeSession { let start = Instant::now(); let start_path: &Path = &self.root_project.file_location; - let instance_context = InstanceContext::new(); + let mut instance_context = InstanceContext::new(); + instance_context.sync_scripts_only = self.sync_scripts_only(); let snapshot = match snapshot_from_vfs(&instance_context, &self.vfs, start_path) { Ok(s) => s, diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs index 82612f30..9fb96cce 100644 --- a/src/snapshot/metadata.rs +++ b/src/snapshot/metadata.rs @@ -155,6 +155,8 @@ pub struct InstanceContext { pub path_ignore_rules: Arc>, #[serde(skip_serializing_if = "Vec::is_empty")] pub sync_rules: Vec, + #[serde(skip)] + pub sync_scripts_only: bool, } impl InstanceContext { @@ -162,6 +164,7 @@ impl InstanceContext { Self { path_ignore_rules: Arc::new(Vec::new()), sync_rules: Vec::new(), + sync_scripts_only: false, } } diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs index f8ca439e..077e7f12 100644 --- a/src/snapshot_middleware/mod.rs +++ b/src/snapshot_middleware/mod.rs @@ -79,6 +79,13 @@ pub fn snapshot_from_vfs( // If and when we do, make sure to go support it in // `Project::set_file_name`, as right now it special-cases // `default.project.json5` as an `init` path. + if context.sync_scripts_only + && !middleware.is_script() + && middleware != Middleware::Dir + && middleware != Middleware::Project + { + return Ok(None); + } match middleware { Middleware::Dir => middleware.snapshot(context, vfs, path, dir_name), _ => middleware.snapshot(context, vfs, &init_path, dir_name), @@ -168,11 +175,23 @@ fn snapshot_from_path( // name is needed (e.g. for names with special chars), it comes from the // `name` field in adjacent .meta.json / .model.json files. if let Some(rule) = context.get_user_sync_rule(path) { + if context.sync_scripts_only + && !rule.middleware.is_script() + && rule.middleware != Middleware::Project + { + return Ok(None); + } let name = rule.file_name_for_path(path)?; return rule.middleware.snapshot(context, vfs, path, name); } else { for rule in default_sync_rules() { if rule.matches(path) { + if context.sync_scripts_only + && !rule.middleware.is_script() + && rule.middleware != Middleware::Project + { + return Ok(None); + } let name = rule.file_name_for_path(path)?; return rule.middleware.snapshot(context, vfs, path, name); } @@ -319,6 +338,27 @@ impl Middleware { } } + /// Returns whether this middleware produces a script instance + /// (Script, LocalScript, or ModuleScript). + #[inline] + pub fn is_script(&self) -> bool { + matches!( + self, + Self::ServerScript + | Self::ClientScript + | Self::ModuleScript + | Self::PluginScript + | Self::LocalScript + | Self::LegacyScript + | Self::ServerScriptDir + | Self::ClientScriptDir + | Self::ModuleScriptDir + | Self::PluginScriptDir + | Self::LocalScriptDir + | Self::LegacyScriptDir + ) + } + /// Returns whether this particular middleware would become a directory. #[inline] pub fn is_dir(&self) -> bool { @@ -454,3 +494,231 @@ pub fn default_sync_rules() -> &'static [SyncRule] { ] }) } + +/// Returns whether a filesystem path is relevant in scripts-only mode. +/// +/// Matches script files (`.luau`, `.lua`), meta files (`.meta.json5`, +/// `.meta.json`), and project files (`.project.json5`, `.project.json`). +pub fn is_script_relevant_path(path: &Path) -> bool { + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_lowercase(), + None => return false, + }; + name.ends_with(".luau") + || name.ends_with(".lua") + || name.ends_with(".meta.json5") + || name.ends_with(".meta.json") + || name.ends_with(".project.json5") + || name.ends_with(".project.json") +} + +#[cfg(test)] +mod test { + use super::*; + use std::collections::HashMap; + + use memofs::{InMemoryFs, VfsSnapshot}; + + #[test] + fn is_script_covers_all_script_types() { + assert!(Middleware::ServerScript.is_script()); + assert!(Middleware::ClientScript.is_script()); + assert!(Middleware::ModuleScript.is_script()); + assert!(Middleware::PluginScript.is_script()); + assert!(Middleware::LocalScript.is_script()); + assert!(Middleware::LegacyScript.is_script()); + assert!(Middleware::ServerScriptDir.is_script()); + assert!(Middleware::ClientScriptDir.is_script()); + assert!(Middleware::ModuleScriptDir.is_script()); + assert!(Middleware::PluginScriptDir.is_script()); + assert!(Middleware::LocalScriptDir.is_script()); + assert!(Middleware::LegacyScriptDir.is_script()); + } + + #[test] + fn is_script_excludes_non_scripts() { + assert!(!Middleware::Dir.is_script()); + assert!(!Middleware::Project.is_script()); + assert!(!Middleware::Csv.is_script()); + assert!(!Middleware::JsonModel.is_script()); + assert!(!Middleware::Json.is_script()); + assert!(!Middleware::Rbxm.is_script()); + assert!(!Middleware::Rbxmx.is_script()); + assert!(!Middleware::Toml.is_script()); + assert!(!Middleware::Text.is_script()); + assert!(!Middleware::Yaml.is_script()); + assert!(!Middleware::Ignore.is_script()); + assert!(!Middleware::CsvDir.is_script()); + } + + #[test] + fn scripts_only_skips_non_script_file() { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/project", + VfsSnapshot::dir(HashMap::from([ + ("data.json5", VfsSnapshot::file("{}")), + ("script.server.luau", VfsSnapshot::file("print('hi')")), + ])), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + let mut context = InstanceContext::new(); + context.sync_scripts_only = true; + + let json_result = + snapshot_from_vfs(&context, &vfs, Path::new("/project/data.json5")).unwrap(); + assert!(json_result.is_none()); + + let script_result = + snapshot_from_vfs(&context, &vfs, Path::new("/project/script.server.luau")).unwrap(); + assert!(script_result.is_some()); + assert_eq!(script_result.unwrap().class_name.as_str(), "Script"); + } + + #[test] + fn scripts_only_preserves_directories() { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/project", + VfsSnapshot::dir(HashMap::from([( + "models", + VfsSnapshot::dir(HashMap::from([( + "part.rbxm", + VfsSnapshot::file(b"\x00".as_ref()), + )])), + )])), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + let mut context = InstanceContext::new(); + context.sync_scripts_only = true; + + let result = snapshot_from_vfs(&context, &vfs, Path::new("/project/models")).unwrap(); + assert!(result.is_some()); + let snapshot = result.unwrap(); + assert_eq!(snapshot.class_name.as_str(), "Folder"); + assert!(snapshot.children.is_empty()); + } + + #[test] + fn scripts_only_allows_project_files() { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/nested.project.json5", + VfsSnapshot::file(r#"{"name": "Nested", "tree": {"$className": "Folder"}}"#), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + let mut context = InstanceContext::new(); + context.sync_scripts_only = true; + + let result = snapshot_from_vfs(&context, &vfs, Path::new("/nested.project.json5")).unwrap(); + assert!(result.is_some()); + } + + #[test] + fn normal_mode_includes_all_files() { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/project", + VfsSnapshot::dir(HashMap::from([ + ("data.json5", VfsSnapshot::file("{}")), + ("script.server.luau", VfsSnapshot::file("print('hi')")), + ])), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + let context = InstanceContext::new(); + + let json_result = + snapshot_from_vfs(&context, &vfs, Path::new("/project/data.json5")).unwrap(); + assert!(json_result.is_some()); + + let script_result = + snapshot_from_vfs(&context, &vfs, Path::new("/project/script.server.luau")).unwrap(); + assert!(script_result.is_some()); + } + + #[test] + fn is_script_relevant_path_accepts_scripts_and_meta() { + assert!(is_script_relevant_path(Path::new("/src/main.luau"))); + assert!(is_script_relevant_path(Path::new("/src/init.server.luau"))); + assert!(is_script_relevant_path(Path::new("/src/old.lua"))); + assert!(is_script_relevant_path(Path::new("/src/old.server.lua"))); + assert!(is_script_relevant_path(Path::new("/src/file.meta.json5"))); + assert!(is_script_relevant_path(Path::new("/src/file.meta.json"))); + assert!(is_script_relevant_path(Path::new("/nested.project.json5"))); + assert!(is_script_relevant_path(Path::new("/nested.project.json"))); + } + + #[test] + fn is_script_relevant_path_rejects_non_scripts() { + assert!(!is_script_relevant_path(Path::new("/src/data.json5"))); + assert!(!is_script_relevant_path(Path::new("/src/model.rbxm"))); + assert!(!is_script_relevant_path(Path::new("/src/notes.txt"))); + assert!(!is_script_relevant_path(Path::new("/src/table.csv"))); + assert!(!is_script_relevant_path(Path::new("/src/config.toml"))); + assert!(!is_script_relevant_path(Path::new("/src/data.yaml"))); + assert!(!is_script_relevant_path(Path::new("/src/model.rbxmx"))); + assert!(!is_script_relevant_path(Path::new("/src"))); + assert!(!is_script_relevant_path(Path::new("/src/no_ext"))); + } + + #[test] + fn scripts_only_filters_csv_dir() { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/project", + VfsSnapshot::dir(HashMap::from([( + "localization", + VfsSnapshot::dir(HashMap::from([( + "init.csv", + VfsSnapshot::file("Key,Source,Example\nkey1,Hello,Hello"), + )])), + )])), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + let mut context = InstanceContext::new(); + context.sync_scripts_only = true; + + let result = snapshot_from_vfs(&context, &vfs, Path::new("/project/localization")).unwrap(); + assert!( + result.is_none(), + "CsvDir should be filtered in scripts-only mode" + ); + } + + #[test] + fn scripts_only_preserves_script_dir() { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/project", + VfsSnapshot::dir(HashMap::from([( + "MyScript", + VfsSnapshot::dir(HashMap::from([( + "init.server.luau", + VfsSnapshot::file("print('hello')"), + )])), + )])), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + let mut context = InstanceContext::new(); + context.sync_scripts_only = true; + + let result = snapshot_from_vfs(&context, &vfs, Path::new("/project/MyScript")).unwrap(); + assert!( + result.is_some(), + "Script dir should pass through in scripts-only mode" + ); + assert_eq!(result.unwrap().class_name.as_str(), "Script"); + } +}