Merge pull request #7 from UserGeneratedLLC/vfs_exclusions

Enhance VFS watch functionality and introduce recording mode
This commit is contained in:
CoderJoey 2026-03-06 18:46:14 -06:00 • committed by GitHub
commit f1992a61b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 533 additions and 60 deletions

View file

@ -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

View file

@ -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(())
}

View file

@ -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(),
});

View file

@ -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"))
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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,

View file

@ -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,
}
}

View file

@ -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");
}
}