mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-22 21:41:37 +00:00
Merge pull request #7 from UserGeneratedLLC/vfs_exclusions
Enhance VFS watch functionality and introduce recording mode
This commit is contained in:
commit
f1992a61b1
9 changed files with 533 additions and 60 deletions
12
CHANGELOG.md
12
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
|
||||
|
||||
<details>
|
||||
<summary>Full commit log</summary>
|
||||
|
||||
- `2ea1d493` Enhance VFS watch functionality and introduce recording mode
|
||||
- `bfb5815f` Update TODO and enhance git configuration
|
||||
|
||||
</details>
|
||||
|
||||
## [8.5.2] (March 3rd, 2026)
|
||||
|
||||
* Fix `atlas init` to commit project files before adding agent submodules, producing cleaner git history
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<VfsEvent>;
|
||||
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<dyn VfsBackend>,
|
||||
watch_enabled: bool,
|
||||
watch_recursive: bool,
|
||||
prefetch_cache: Option<PrefetchCache>,
|
||||
recorded_watch_paths: Option<HashSet<PathBuf>>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
||||
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<HashSet<PathBuf>> {
|
||||
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<P: AsRef<Path>>(&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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ pub struct StdBackend {
|
|||
debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>,
|
||||
watcher_receiver: Receiver<VfsEvent>,
|
||||
watches: HashSet<PathBuf>,
|
||||
recursive_watches: HashSet<PathBuf>,
|
||||
/// Receiver for critical errors from the watcher thread.
|
||||
/// Callers should poll this to detect when watching becomes unreliable.
|
||||
critical_error_receiver: Receiver<WatcherCriticalError>,
|
||||
|
|
@ -113,6 +114,7 @@ impl StdBackend {
|
|||
debouncer,
|
||||
watcher_receiver: event_rx,
|
||||
watches: HashSet::new(),
|
||||
recursive_watches: HashSet::new(),
|
||||
critical_error_receiver: error_rx,
|
||||
}
|
||||
}
|
||||
|
|
@ -328,20 +330,42 @@ impl VfsBackend for StdBackend {
|
|||
self.watcher_receiver.clone()
|
||||
}
|
||||
|
||||
fn watch(&mut self, path: &Path) -> io::Result<()> {
|
||||
if self.watches.contains(path)
|
||||
|| path
|
||||
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()
|
||||
.any(|ancestor| self.watches.contains(ancestor))
|
||||
.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) {
|
||||
RecursiveMode::NonRecursive
|
||||
};
|
||||
match self.debouncer.watch(path, mode) {
|
||||
Ok(()) => {
|
||||
log::debug!("Watching path: {}", path.display());
|
||||
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) => {
|
||||
|
|
@ -350,7 +374,6 @@ impl VfsBackend for StdBackend {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, path: &Path) -> io::Result<()> {
|
||||
let was_watched = self.watches.contains(path);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Receiver<memofs::WatcherCriticalError>>,
|
||||
git_repo_root: Option<PathBuf>,
|
||||
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<PathBuf>,
|
||||
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -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<PrefetchCache> {
|
||||
fn prefetch_project_files(project: &Project, sync_scripts_only: bool) -> io::Result<PrefetchCache> {
|
||||
use rayon::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use walkdir::WalkDir;
|
||||
|
|
@ -200,6 +200,7 @@ fn prefetch_project_files(project: &Project) -> io::Result<PrefetchCache> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@ pub struct InstanceContext {
|
|||
pub path_ignore_rules: Arc<Vec<PathIgnoreRule>>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub sync_rules: Vec<SyncRule>,
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue