diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index e45d346a0..372cb97a6 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -305,12 +305,13 @@ impl Host { sink.clone(), Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); - index.update(&changed, &batch.removed); + let (ignored, ignored_gone) = ignored_changes(batch); + index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat()); kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs); // Atomic saves by external tools surface as `created`. - documents.on_disk_changed(&changed); + documents.on_disk_changed(&[&changed[..], &ignored].concat()); // A framework's root marker (`angular.json`) created or deleted: its // templates gain or lose their mode. Markers in `node_modules` only // count here when the folder isn't excluded (it is by default). @@ -961,6 +962,11 @@ const HISTORY_BATCH_LIMIT: usize = 500; /// Local History for changes made outside the IDE (other tools, VCS, deletions). /// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped. +/// The batch's ignored files (`.env`), split into those still on disk and those removed. +fn ignored_changes(batch: &ide_vfs::FsBatch) -> (Vec, Vec) { + batch.ignored.iter().cloned().partition(|p| p.symlink_metadata().is_ok()) +} + fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) { for path in changed.iter().take(HISTORY_BATCH_LIMIT) { if let Err(e) = history.record_file(path, "External change", false) { @@ -1045,11 +1051,12 @@ fn content_root_watcher( sink.clone(), Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); - index.update(&changed, &batch.removed); + let (ignored, ignored_gone) = ignored_changes(batch); + index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat()); if let Some(bridge) = &kotlin_bridge { bridge.files_changed(changed.iter().chain(&batch.removed)); } - documents.on_disk_changed(&changed); + documents.on_disk_changed(&[&changed[..], &ignored].concat()); record_external_changes(&history, &changed, &batch.removed); }), ) diff --git a/crates/ide-index/src/data.rs b/crates/ide-index/src/data.rs index 57bb76e42..1bda8c243 100644 --- a/crates/ide-index/src/data.rs +++ b/crates/ide-index/src/data.rs @@ -40,6 +40,8 @@ pub(crate) struct FileRecord { pub size: u64, pub hash: [u8; 32], pub symbols: Vec, + /// Matched by an ignore file: listed by name only (no hash, no symbols). + pub ignored: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -72,6 +74,7 @@ pub(crate) struct FileEntry { pub size: u64, pub hash: [u8; 32], pub symbols: Box<[Sym]>, + pub ignored: bool, } impl FileEntry { @@ -100,6 +103,7 @@ impl FileEntry { size: record.size, hash: record.hash, symbols, + ignored: record.ignored, } } @@ -126,8 +130,14 @@ impl FileEntry { range: s.range, }) .collect(), + ignored: self.ignored, } } + + /// Whether the entry is out of date for `found` on disk. + pub(crate) fn stale_for(&self, found: &crate::filter::Found) -> bool { + self.mtime != found.mtime || self.size != found.size || self.ignored != found.ignored + } } #[derive(Default)] diff --git a/crates/ide-index/src/filter.rs b/crates/ide-index/src/filter.rs index 0a612b31a..bae11d654 100644 --- a/crates/ide-index/src/filter.rs +++ b/crates/ide-index/src/filter.rs @@ -1,10 +1,16 @@ //! Which paths belong in the index: `.gitignore`/`.ignore` rules (via the //! `ignore` crate) plus the user's `files.exclude` globs, below the project root //! and the extra content roots (Project Structure). +//! +//! Like IDEA, ignored directories are left out entirely, while ignored files in +//! the remaining directories (`.env`) are listed by name only, so Go to File finds +//! them but their symbols stay out of the index. use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; use ignore::gitignore::{Gitignore, GitignoreBuilder}; use ignore::{WalkBuilder, WalkState}; +use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; @@ -57,11 +63,25 @@ fn rel_string(rel: &Path) -> String { out } +/// Whether `path` below `root` matches the exclude globs. +fn glob_excluded(excludes: &GlobSet, root: &Path, path: &Path) -> bool { + match path.strip_prefix(root) { + Ok(rel) if !rel.as_os_str().is_empty() => { + let rel = rel.to_string_lossy(); + let rel = if std::path::MAIN_SEPARATOR == '/' { rel } else { rel.replace('\\', "/").into() }; + excludes.is_match(rel.as_ref()) + } + _ => false, + } +} + #[derive(Clone, Debug)] pub(crate) struct Found { pub path: PathBuf, pub mtime: u64, pub size: u64, + /// Matched by an ignore file: listed by name only. + pub ignored: bool, } pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 { @@ -177,8 +197,9 @@ impl PathFilter { ALWAYS_SKIP.contains(&name) } - /// Whether a path reported by the watcher should be ignored (outside the - /// root, inside an excluded/ignored directory, or ignored itself). + /// Whether a path reported by the watcher should be left out (outside the + /// root, excluded, or an ignored directory or inside one). Ignored files + /// elsewhere stay in, listed by name only. pub(crate) fn is_excluded(&self, path: &Path, is_dir: bool) -> bool { let Some((base, _, gitignore)) = self.base_of(path) else { return true }; let rel = path.strip_prefix(base).map(rel_string).unwrap_or_default(); @@ -198,16 +219,27 @@ impl PathFilter { return true; } } - gitignore.matched_path_or_any_parents(path, is_dir).is_ignore() + let dir = if is_dir { Some(path) } else { path.parent().filter(|p| *p != base) }; + dir.is_some_and(|d| gitignore.matched_path_or_any_parents(d, true).is_ignore()) } /// All indexable files at or below `dir`, walked in parallel. /// Stops early (returning what was found so far) once `cancel` is set. pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec { + self.walk_to(dir, None, cancel) + } + + /// The indexable files directly in `dir`, classified by the same rules as [`Self::walk`]. + pub(crate) fn list_dir(&self, dir: &Path) -> Vec { + self.walk_to(dir, Some(1), &AtomicBool::new(false)) + } + + fn walk_to(&self, dir: &Path, max_depth: Option, cancel: &AtomicBool) -> Vec { let (tx, rx) = mpsc::channel::>(); let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() }; let root = root.to_path_buf(); let excludes = self.excludes.clone(); + let (walk_root, walk_excludes) = (root.clone(), excludes.clone()); let walker = WalkBuilder::new(dir) .hidden(false) .parents(true) @@ -217,41 +249,63 @@ impl PathFilter { .git_exclude(true) .require_git(ide_core::ignored::inside_git_repo(dir)) .follow_links(false) + .max_depth(max_depth) .filter_entry(move |e| { let name = e.file_name().to_string_lossy(); - if Self::skip_name(&name) { - return false; - } - match e.path().strip_prefix(&root) { - Ok(rel) if !rel.as_os_str().is_empty() => { - let rel = rel.to_string_lossy(); - let rel = if std::path::MAIN_SEPARATOR == '/' { rel } else { rel.replace('\\', "/").into() }; - !excludes.is_match(rel.as_ref()) - } - _ => true, - } + !Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path()) }) .build_parallel(); walker.run(|| { + let (root, excludes) = (&root, &excludes); let mut batch = Batch { tx: tx.clone(), items: Vec::with_capacity(256) }; Box::new(move |entry| { if cancel.load(Ordering::Relaxed) { return WalkState::Quit; } let Ok(entry) = entry else { return WalkState::Continue }; - if entry.file_type().is_some_and(|t| t.is_file()) + let Some(file_type) = entry.file_type() else { return WalkState::Continue }; + if file_type.is_dir() && max_depth.is_none_or(|max| entry.depth() < max) { + // The walker drops ignored files unseen: list every file of a + // walked directory; those the walker doesn't report are ignored. + let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue }; + for child in children.flatten() { + let path = child.path(); + if child.file_type().is_ok_and(|t| t.is_file()) + && !Self::skip_name(&child.file_name().to_string_lossy()) + && !glob_excluded(excludes, root, &path) + && let Ok(meta) = child.metadata() + { + batch.items.push(Found { path, mtime: mtime_of(&meta), size: meta.len(), ignored: true }); + } + } + } else if file_type.is_file() && let Ok(meta) = entry.metadata() { - batch.items.push(Found { path: entry.into_path(), mtime: mtime_of(&meta), size: meta.len() }); - if batch.items.len() >= 256 && batch.tx.send(std::mem::take(&mut batch.items)).is_err() { - return WalkState::Quit; - } + batch.items.push(Found { + path: entry.into_path(), + mtime: mtime_of(&meta), + size: meta.len(), + ignored: false, + }); + } + if batch.items.len() >= 256 && batch.tx.send(std::mem::take(&mut batch.items)).is_err() { + return WalkState::Quit; } WalkState::Continue }) }); drop(tx); - rx.into_iter().flatten().collect() + // Each file comes once from its directory listing and, unless ignored, once from the walker. + let mut by_path: HashMap = HashMap::new(); + for f in rx.into_iter().flatten() { + match by_path.entry(f.path.clone()) { + Entry::Occupied(mut e) => e.get_mut().ignored &= f.ignored, + Entry::Vacant(e) => { + e.insert(f); + } + } + } + by_path.into_values().collect() } } @@ -311,6 +365,84 @@ mod tests { assert!(!filter.is_excluded(&root.join("src/new.rs"), false)); } + #[test] + fn ignored_files_are_listed_but_ignored_directories_are_not() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + for f in [ + ".env", + "src/a.rs", + "src/debug.log", + "src/local/secrets.json", + "pkg/.gitignore", + "pkg/gen.rs", + "pkg/keep.rs", + "build/out.rs", + "build/nested/deep.rs", + "logs/app.log", + "node_modules/x/.env", + "src/.DS_Store", + "src/.git", + ] { + touch(&root, f); + } + std::fs::write( + root.join(".gitignore"), + ".env +*.log +build/ +logs/ +src/local/ +", + ) + .unwrap(); + std::fs::write( + root.join("pkg/.gitignore"), + "gen.rs +", + ) + .unwrap(); + let filter = PathFilter::new(&root, &vec![].into(), &["**/node_modules".into(), "**/.DS_Store".into()]); + let mut found: Vec<(String, bool)> = filter + .walk(&root, &AtomicBool::new(false)) + .iter() + .filter_map(|f| Some((filter.rel(&f.path)?, f.ignored))) + .collect(); + found.sort(); + let expected = [ + (".env", true), + (".gitignore", false), + ("pkg/.gitignore", false), + ("pkg/gen.rs", true), + ("pkg/keep.rs", false), + ("src/a.rs", false), + ("src/debug.log", true), + ]; + assert_eq!(found, expected.map(|(p, i)| (p.to_owned(), i))); + + // One directory at a time (watcher updates): same classification, no descent. + let mut listed: Vec<(String, bool)> = + filter.list_dir(&root.join("pkg")).iter().filter_map(|f| Some((filter.rel(&f.path)?, f.ignored))).collect(); + listed.sort(); + let expected = [("pkg/.gitignore", false), ("pkg/gen.rs", true), ("pkg/keep.rs", false)]; + assert_eq!(listed, expected.map(|(p, i)| (p.to_owned(), i))); + let top: Vec = filter.list_dir(&root).iter().filter_map(|f| filter.rel(&f.path)).collect(); + assert!(top.iter().all(|p| !p.contains('/')), "{top:?}"); + + for (rel, excluded) in [ + (".env", false), + ("src/new.log", false), + ("src/new.rs", false), + ("build/x.rs", true), + ("logs/app.log", true), + ("src/local/x.json", true), + ] { + assert_eq!(filter.is_excluded(&root.join(rel), false), excluded, "{rel}"); + } + assert!(filter.is_excluded(&root.join("build"), true)); + assert!(!filter.is_excluded(&root.join("src"), true)); + } + #[test] fn a_nested_repository_is_not_hidden_by_the_outer_repositorys_gitignore() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-index/src/store.rs b/crates/ide-index/src/store.rs index 7419c7092..f32dac55a 100644 --- a/crates/ide-index/src/store.rs +++ b/crates/ide-index/src/store.rs @@ -8,7 +8,7 @@ use std::path::Path; const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files"); const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta"); /// Bump when [`FileRecord`] or its encoding changes. -const SCHEMA: &str = "openidea-index-2"; +const SCHEMA: &str = "openidea-index-3"; type Result = std::result::Result; diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index 667e9f505..ba610dc7f 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -293,6 +293,56 @@ fn binary_and_huge_files_are_listed_without_symbols() { assert_eq!(idx.status().symbols, 0); } +#[test] +fn go_to_file_finds_ignored_files_but_not_their_symbols() { + let fx = Fixture::new(&[ + (".gitignore", ".env\n*.local.ts\ndist/\n"), + (".env", "SECRET=1\n"), + ("src/app.ts", "export function app() {}\n"), + ("src/config.local.ts", "export function localOnly() {}\n"), + ("dist/bundle.js", "function bundled() {}\n"), + ]); + let idx = fx.open(); + assert_eq!(files(&idx, ".env"), vec![".env"], "the reported case: a git-ignored root file"); + assert_eq!(files(&idx, "config.local"), vec!["src/config.local.ts"]); + assert!(files(&idx, "bundle").is_empty(), "ignored directories stay out"); + assert!(symbols(&idx, "localOnly", SymbolFilter::All).is_empty(), "ignored files are listed by name only"); + assert_eq!(symbols(&idx, "app", SymbolFilter::All), vec!["app"]); + + // Watcher updates keep the distinction. + let fresh = fx.write("src/fresh.local.ts", "export function freshLocal() {}\n"); + let bundled = fx.write("dist/more.js", "function more() {}\n"); + idx.update(&[fresh, bundled], &[]); + assert!(idx.wait_idle(WAIT)); + assert_eq!(files(&idx, "fresh.local"), vec!["src/fresh.local.ts"]); + assert!(symbols(&idx, "freshLocal", SymbolFilter::All).is_empty()); + assert!(files(&idx, "more.js").is_empty()); + + // Nested ignore files apply to watcher updates as they do to the scan. + fx.write("pkg/.gitignore", "gen.ts\n"); + let generated = fx.write("pkg/gen.ts", "export function generatedThing() {}\n"); + let kept = fx.write("pkg/kept.ts", "export function keptThing() {}\n"); + idx.update(&[generated, kept], &[]); + assert!(idx.wait_idle(WAIT)); + assert_eq!(files(&idx, "pkg/gen"), vec!["pkg/gen.ts"]); + assert!(symbols(&idx, "generatedThing", SymbolFilter::All).is_empty()); + assert_eq!(symbols(&idx, "keptThing", SymbolFilter::All), vec!["keptThing"]); + drop(idx); + + // No longer ignored: the next scan parses it although its mtime is unchanged. + std::fs::write(fx.root.join(".gitignore"), ".env\ndist/\n").unwrap(); + let idx = fx.open(); + assert_eq!(symbols(&idx, "localOnly", SymbolFilter::All), vec!["localOnly"]); + assert_eq!(files(&idx, ".env"), vec![".env"]); + drop(idx); + + // Ignored again: its symbols are dropped. + std::fs::write(fx.root.join(".gitignore"), "*.local.ts\n").unwrap(); + let idx = fx.open(); + assert!(symbols(&idx, "localOnly", SymbolFilter::All).is_empty()); + assert_eq!(files(&idx, "bundle"), vec!["dist/bundle.js"]); +} + #[test] fn second_window_on_same_project_works_in_memory() { let fx = sample(); diff --git a/crates/ide-index/src/worker.rs b/crates/ide-index/src/worker.rs index a69472713..3928edd42 100644 --- a/crates/ide-index/src/worker.rs +++ b/crates/ide-index/src/worker.rs @@ -2,13 +2,13 @@ //! updates queued by the watcher. use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol}; -use crate::filter::{Found, PathFilter, mtime_of}; +use crate::filter::{Found, PathFilter}; use crate::store::Store; use ide_core::{IdeEvent, SharedSink, TaskId}; use ide_language::{LanguageRegistry, Syntax}; use parking_lot::{Condvar, Mutex, RwLock}; use rayon::prelude::*; -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -233,10 +233,7 @@ impl Shared { let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect(); let (todo, stale): (Vec<&Found>, Vec) = { let d = self.data.read(); - let todo = found - .iter() - .filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size)) - .collect(); + let todo = found.iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect(); let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect(); (todo, stale) }; @@ -311,6 +308,7 @@ impl Shared { let mut rec = d.get(&f.path)?.record(); rec.mtime = f.mtime; rec.size = f.size; + rec.ignored = f.ignored; Some((rel, rec, f.path.clone())) } Extracted::New(rec) => Some((rel, rec, f.path.clone())), @@ -342,8 +340,8 @@ impl Shared { } fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted { - let mut rec = FileRecord { mtime: f.mtime, size: f.size, ..Default::default() }; - if f.size > MAX_SYMBOL_FILE { + let mut rec = FileRecord { mtime: f.mtime, size: f.size, ignored: f.ignored, ..Default::default() }; + if f.ignored || f.size > MAX_SYMBOL_FILE { return Extracted::New(rec); } let mut bytes = Vec::with_capacity(f.size as usize); @@ -420,6 +418,7 @@ impl Shared { let mut files: Vec = Vec::new(); let mut seen = HashSet::new(); + let mut by_dir: HashMap> = HashMap::new(); for path in changed { if !seen.insert(path.clone()) { continue; @@ -430,16 +429,19 @@ impl Shared { } if meta.is_dir() { files.extend(filter.walk(&path, &self.cancelled)); - } else if meta.is_file() { - files.push(Found { mtime: mtime_of(&meta), size: meta.len(), path }); + } else if meta.is_file() + && let Some(dir) = path.parent() + { + by_dir.entry(dir.to_owned()).or_default().insert(path); } } + // Listing the folder applies every ignore file (nested, global) to tell ignored files apart. + for (dir, wanted) in by_dir { + files.extend(filter.list_dir(&dir).into_iter().filter(|f| wanted.contains(&f.path))); + } let files: Vec = { let d = self.data.read(); - files - .into_iter() - .filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size)) - .collect() + files.into_iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect() }; if !files.is_empty() { touched = true; @@ -457,7 +459,7 @@ impl Shared { .filter(|p| before.detect(p, None) != self.languages.detect(p, None)) .filter_map(|p| { let e = d.get(p)?; - Some(Found { path: p.clone(), mtime: e.mtime, size: e.size }) + Some(Found { path: p.clone(), mtime: e.mtime, size: e.size, ignored: e.ignored }) }) .collect() }; diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index c8059984d..2dd5a0e44 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -325,6 +325,11 @@ pub struct FsBatch { /// Folders holding changes filtered out as excluded or ignored. Only a view already /// showing such a folder cares, so they don't count as worktree changes. pub excluded_dirs: Vec, + /// Files changed, created or removed that an ignore rule matches while their folder + /// is not ignored (`.env`). Kept apart from the worktree changes above (no Local + /// History, deployment or `fsChanged`), but the index lists them and open editors + /// follow them, like IDEA, where only ignored folders leave the project. + pub ignored: Vec, } impl FsBatch { @@ -334,6 +339,7 @@ impl FsBatch { && self.removed.is_empty() && self.vcs.is_empty() && self.excluded_dirs.is_empty() + && self.ignored.is_empty() } } diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index d9309d90b..39c1d35b0 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -531,6 +531,13 @@ impl EventFilter { && !self.gitignore.is_ignored(path, is_dir) } + /// Whether a filtered-out file is ignored by a rule on the file itself, in a folder + /// that is neither excluded nor ignored. + fn is_ignored_file(&self, path: &Path, rel: &Path) -> bool { + !self.excludes.read().is_excluded(rel) + && path.parent().is_some_and(|dir| dir == self.root || !self.gitignore.is_ignored(dir, true)) + } + fn classify(&self, path: &Path, change: Change, out: &mut BTreeMap) { let rel = match path.strip_prefix(&self.root) { Ok(rel) if rel.as_os_str().is_empty() => return, @@ -642,6 +649,9 @@ impl EventFilter { if change != Change::Modified { excluded_dirs.extend(path.parent().map(Path::to_path_buf)); } + if !is_dir && self.is_ignored_file(&path, rel) { + batch.ignored.push(path); + } continue; } } @@ -700,6 +710,7 @@ mod tests { assert_eq!(batch.vcs, vec![root.join(".git/HEAD"), root.join(".git/refs/heads/feature")]); assert_eq!(batch.modified, vec![root.join("kept.txt")]); assert_eq!(batch.removed, vec![root.join("from.txt"), root.join("gone.txt"), root.join("old.txt")]); + assert_eq!(batch.ignored, vec![root.join("debug.log")], "ignored files are reported apart"); } #[test] @@ -751,6 +762,20 @@ mod tests { ]); assert!(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty(), "{batch:?}"); assert_eq!(batch.excluded_dirs, vec![root.clone(), root.join("dist"), root.join("target/debug")]); + assert_eq!(batch.ignored, vec![root.join("debug.log")], "only the ignored file outside ignored folders"); + + // An ignored file's edit and removal reach the index too (nothing else changes). + let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content)); + let batch = filter.batch(&[ev(modify, vec![root.join("debug.log")])]); + assert_eq!(batch.ignored, vec![root.join("debug.log")]); + assert!(!batch.is_empty() && batch.excluded_dirs.is_empty()); + std::fs::remove_file(root.join("debug.log")).unwrap(); + let batch = filter.batch(&[ + ev(EventKind::Remove(RemoveKind::File), vec![root.join("debug.log")]), + ev(EventKind::Remove(RemoveKind::File), vec![root.join("dist/app.js")]), + ]); + // A removed path can't tell file from folder: it goes out as a removal, as before. + assert_eq!(batch.removed, vec![root.join("debug.log")]); // Sibling files in unignored directories still come through. let batch = filter.batch(&[ev(create_file, vec![root.join("src/main.rs")])]); @@ -801,6 +826,7 @@ mod tests { |paths: &[&str]| -> Vec { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() }; let (batch, rules) = filter.batch_detailed(&touch(&["pkg/a.tmp", "pkg/gen/x.rs", "pkg/keep.rs", "b.tmp"])); assert_eq!(batch.modified, vec![root.join("b.tmp"), root.join("pkg/keep.rs")]); + assert_eq!(batch.ignored, vec![root.join("pkg/a.tmp")], "nested rules, but not inside ignored folders"); assert!(rules.is_empty()); assert!(!filter.should_watch_dir(&root.join("pkg/gen"))); @@ -818,7 +844,8 @@ mod tests { std::fs::create_dir_all(root.join(".git/info")).unwrap(); std::fs::write(root.join(".git/info/exclude"), "b.tmp\n").unwrap(); let (batch, rules) = filter.batch_detailed(&touch(&[".git/info/exclude", "b.tmp"])); - assert!(batch.is_empty(), "{batch:?}"); + assert!(batch.modified.is_empty() && batch.vcs.is_empty(), "{batch:?}"); + assert_eq!(batch.ignored, vec![root.join("b.tmp")]); assert_eq!(rules, vec![root.clone()]); }