diff --git a/Cargo.lock b/Cargo.lock index bfe636b1..94d6bb18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3236,8 +3236,7 @@ dependencies = [ [[package]] name = "zlob" version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e41cb327ac1b7e7e0d4514658500cb5734cd655edbe4e5ffeda69955da9028ee" +source = "git+https://github.com/celados/zlob?rev=a64a9bc87b1d4820ce1e4e2406692935be3b5078#a64a9bc87b1d4820ce1e4e2406692935be3b5078" dependencies = [ "bindgen", "bitflags 2.11.0", diff --git a/Cargo.toml b/Cargo.toml index 4aa7459c..1b043b44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ ignore = "0.4.22" memmap2 = "0.9" mimalloc = "0.1.47" signal-hook-registry = "1.4" -zlob = { version = "=1.6.1" } +zlob = { git = "https://github.com/celados/zlob", rev = "a64a9bc87b1d4820ce1e4e2406692935be3b5078" } mlua = { version = "0.11.1", features = ["module", "luajit"] } neo_frizbee = { version = "0.11.0", features = ["match_end_col"] } diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index 6f9055fc..ab284eb9 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -124,6 +124,7 @@ pub(crate) struct FileSync { /// Ignore rules the walker assembled (zlob backend only). Shared with the /// background watcher so filesystem events can be filtered without libgit2. pub(crate) ignore_rules: Option>, + pub(crate) policy_sources: Arc>, } impl FileSync { @@ -142,6 +143,7 @@ impl FileSync { bigram_overlay: None, chunked_paths: None, ignore_rules: None, + policy_sources: Arc::new(Vec::new()), } } @@ -636,6 +638,10 @@ impl FilePicker { self.sync_data.ignore_rules.clone() } + pub(crate) fn policy_sources(&self) -> Arc> { + Arc::clone(&self.sync_data.policy_sources) + } + pub fn has_mmap_cache(&self) -> bool { self.enable_mmap_cache } @@ -2028,6 +2034,7 @@ impl FileSync { synced_files_count, )?; let ignore_rules = walk_output.ignore_rules.take().map(Arc::new); + let policy_sources = Arc::new(walk_output.policy_sources); let mut pairs = walk_output.pairs; // Sort by (dir_part, filename). This groups files by their directory @@ -2131,6 +2138,7 @@ impl FileSync { bigram_overlay: None, chunked_paths: Some(Arc::new(chunked_paths)), ignore_rules, + policy_sources, }) } } diff --git a/crates/fff-core/src/ignore.rs b/crates/fff-core/src/ignore.rs index ac29f523..372f79d6 100644 --- a/crates/fff-core/src/ignore.rs +++ b/crates/fff-core/src/ignore.rs @@ -1,4 +1,84 @@ -use std::path::Path; +use git2::{Config, Repository}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default)] +pub(crate) struct GitIgnorePolicy { + pub(crate) base_document: String, + pub(crate) ignore_files: Vec, + pub(crate) sources: Vec, +} + +impl GitIgnorePolicy { + pub(crate) fn discover(base_path: &Path) -> Self { + let repo = Repository::discover(base_path).ok(); + // User excludes are process-wide Git policy, not repository metadata. + // `open_default` keeps non-Git vaults on the same contract as Git roots. + let global = repo + .as_ref() + .map(Repository::config) + .unwrap_or_else(Config::open_default) + .and_then(|mut config| config.snapshot()) + .and_then(|config| config.get_path("core.excludesFile")) + .ok() + .or_else(default_global_excludes_path); + + let mut policy = Self::default(); + if let Some(path) = global { + policy.add_source(path); + } + if let Some(repo) = &repo { + // Linked worktrees share this file with the primary worktree. + policy.add_source(repo.commondir().join("info/exclude")); + } + policy.sources.extend(config_sources(repo.as_ref())); + policy.sources.sort_unstable(); + policy.sources.dedup(); + policy + } + + fn add_source(&mut self, path: PathBuf) { + if let Ok(content) = std::fs::read_to_string(&path) { + self.base_document.push_str(&content); + if !content.ends_with('\n') { + self.base_document.push('\n'); + } + self.ignore_files.push(path.clone()); + } + self.sources.push(path); + } + + #[cfg(feature = "zlob")] + pub(crate) fn patterns(&self) -> impl Iterator { + self.base_document.lines() + } +} + +fn default_global_excludes_path() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".config"))) + .map(|config| config.join("git/ignore")) +} + +fn config_sources(repo: Option<&Repository>) -> Vec { + let mut paths = [ + Config::find_system().ok(), + Config::find_global().ok(), + Config::find_xdg().ok(), + ] + .into_iter() + .flatten() + .collect::>(); + if let Some(repo) = repo { + paths.push(repo.commondir().join("config")); + // extensions.worktreeConfig stores per-worktree overrides here. + paths.push(repo.path().join("config.worktree")); + } + paths.sort_unstable(); + paths.dedup(); + paths +} /// Directories excluded when walking a non-git root. Entries are `cfg`-gated /// so a single iteration covers standard + platform-specific overrides. @@ -66,3 +146,39 @@ pub(crate) fn is_non_code_directory(path: &Path) -> bool { path_str.contains(dir) }) } + +#[cfg(test)] +mod tests { + use super::GitIgnorePolicy; + use std::fs; + + #[test] + fn policy_document_orders_global_before_info() { + let dir = tempfile::tempdir().unwrap(); + let global = dir.path().join("global-ignore"); + let info = dir.path().join("info-exclude"); + fs::write(&global, "*.tmp").unwrap(); + fs::write(&info, "!keep.tmp\n").unwrap(); + + let mut policy = GitIgnorePolicy::default(); + policy.add_source(global.clone()); + policy.add_source(info.clone()); + + assert_eq!(policy.base_document, "*.tmp\n!keep.tmp\n"); + assert_eq!(policy.ignore_files, vec![global.clone(), info.clone()]); + assert_eq!(policy.sources, vec![global, info]); + } + + #[test] + fn missing_policy_source_is_watched_but_not_loaded() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("future-ignore"); + + let mut policy = GitIgnorePolicy::default(); + policy.add_source(missing.clone()); + + assert!(policy.base_document.is_empty()); + assert!(policy.ignore_files.is_empty()); + assert_eq!(policy.sources, vec![missing]); + } +} diff --git a/crates/fff-core/src/scan.rs b/crates/fff-core/src/scan.rs index 29f1bb32..8f4a43f3 100644 --- a/crates/fff-core/src/scan.rs +++ b/crates/fff-core/src/scan.rs @@ -395,4 +395,11 @@ fn rescubscribe_watcher_post_scan(shared_picker: &SharedFilePicker) { watcher.request_watch_dir(dir.to_path_buf()); std::ops::ControlFlow::Continue(()) }); + for dir in picker + .policy_sources() + .iter() + .filter_map(|source| source.parent()) + { + watcher.request_watch_policy_source_dir(dir.to_path_buf()); + } } diff --git a/crates/fff-core/src/walk/mod.rs b/crates/fff-core/src/walk/mod.rs index a533f0bb..6ada48ec 100644 --- a/crates/fff-core/src/walk/mod.rs +++ b/crates/fff-core/src/walk/mod.rs @@ -21,6 +21,7 @@ pub(crate) use ripgrep::walk_collect_files; pub(crate) struct WalkOutput { pub(crate) pairs: Vec<(FileItem, String)>, pub(crate) ignore_rules: Option, + pub(crate) policy_sources: Vec, } pub(crate) struct WalkIgnoreRules { @@ -143,4 +144,69 @@ mod tests { assert!(rules.is_ignored(Path::new("debug.log"))); assert!(!rules.is_ignored(Path::new("Cargo.toml"))); } + + #[test] + fn nested_negation_prevents_directory_pruning() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir(root.join(".git")).unwrap(); + fs::write(root.join(".gitignore"), "*\n!*.*\n!/**/\n").unwrap(); + fs::create_dir_all(root.join("sub1/sub2")).unwrap(); + fs::write(root.join("top.rs"), "").unwrap(); + fs::write(root.join("sub1/mid.rs"), "").unwrap(); + fs::write(root.join("sub1/sub2/deep.rs"), "").unwrap(); + + let counter = Arc::new(AtomicUsize::new(0)); + let out = walk_collect_files(root, true, false, 1, &counter).unwrap(); + let names: Vec<_> = out.pairs.into_iter().map(|(_, rel)| rel).collect(); + + assert!(names.contains(&"top.rs".to_string()), "got {names:?}"); + assert!(names.contains(&"sub1/mid.rs".to_string()), "got {names:?}"); + assert!( + names.contains(&"sub1/sub2/deep.rs".to_string()), + "got {names:?}" + ); + } + + #[cfg(feature = "zlob")] + #[test] + fn git_exclude_layers_follow_git_precedence() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let repo = git2::Repository::init(root).unwrap(); + let global = root.join("global-ignore"); + fs::write(&global, "*.tmp\n*.info\n").unwrap(); + fs::create_dir_all(repo.commondir().join("info")).unwrap(); + fs::write(repo.commondir().join("info/exclude"), "!info-keep.tmp\n").unwrap(); + fs::write(root.join(".gitignore"), "!root-keep.info\n").unwrap(); + repo.config() + .unwrap() + .set_str("core.excludesFile", global.to_str().unwrap()) + .unwrap(); + for path in [ + "drop.tmp", + "info-keep.tmp", + "drop.info", + "root-keep.info", + "visible.md", + ] { + fs::write(root.join(path), "").unwrap(); + } + + let counter = Arc::new(AtomicUsize::new(0)); + let out = walk_collect_files(root, true, false, 1, &counter).unwrap(); + let names: Vec<_> = out.pairs.into_iter().map(|(_, rel)| rel).collect(); + + assert!(!names.contains(&"drop.tmp".to_string()), "got {names:?}"); + assert!( + names.contains(&"info-keep.tmp".to_string()), + "got {names:?}" + ); + assert!(!names.contains(&"drop.info".to_string()), "got {names:?}"); + assert!( + names.contains(&"root-keep.info".to_string()), + "got {names:?}" + ); + assert!(names.contains(&"visible.md".to_string()), "got {names:?}"); + } } diff --git a/crates/fff-core/src/walk/ripgrep.rs b/crates/fff-core/src/walk/ripgrep.rs index def4599b..a9ce928e 100644 --- a/crates/fff-core/src/walk/ripgrep.rs +++ b/crates/fff-core/src/walk/ripgrep.rs @@ -17,17 +17,26 @@ pub(crate) fn walk_collect_files( threads: usize, synced_files_count: &Arc, ) -> crate::Result { + let policy = crate::ignore::GitIgnorePolicy::discover(base_path); let mut walk_builder = WalkBuilder::new(base_path); walk_builder // this is a very important guard for the user opening ~/ or other root non-git dir .hidden(!is_git_repo) .git_ignore(true) - .git_exclude(true) - .git_global(true) + // User and repository-wide excludes enter through GitIgnorePolicy so + // scan and watcher rebuild from one precedence-ordered source list. + .git_exclude(false) + .git_global(false) .ignore(true) .follow_links(follow_symlinks) .threads(threads); + for path in &policy.ignore_files { + if let Some(error) = walk_builder.add_ignore(path) { + tracing::warn!(?error, path = %path.display(), "Failed to load Git ignore policy source"); + } + } + if !is_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) { walk_builder.overrides(overrides); } @@ -68,5 +77,6 @@ pub(crate) fn walk_collect_files( Ok(WalkOutput { pairs: pairs.into_inner(), ignore_rules: None, + policy_sources: policy.sources, }) } diff --git a/crates/fff-core/src/walk/zlob.rs b/crates/fff-core/src/walk/zlob.rs index 0978ad1b..bff2ff4f 100644 --- a/crates/fff-core/src/walk/zlob.rs +++ b/crates/fff-core/src/walk/zlob.rs @@ -20,6 +20,7 @@ pub(crate) fn walk_collect_files( threads: usize, synced_files_count: &Arc, ) -> crate::Result { + let policy = crate::ignore::GitIgnorePolicy::discover(base_path); // gitignore on; skip hidden on non-git roots (so `~/` doesn't recurse into // ~/.cache, ~/.config, etc.); optionally follow symlinks. let mut flags = WalkFlags::GITIGNORE; @@ -38,6 +39,13 @@ pub(crate) fn walk_collect_files( // Bulk-fetch the only metadata FileItem needs; zlob never stats more. .metadata(WalkMetadata::SIZE | WalkMetadata::MTIME); + let base_patterns = policy.patterns().collect::>(); + if !base_patterns.is_empty() { + builder + .base_ignore(&base_patterns) + .map_err(|e| crate::Error::WalkFailed(format!("base ignore: {e:?}")))?; + } + if !is_git_repo && !IGNORED_DIRS.is_empty() && let Err(e) = builder.extra_ignore(IGNORED_DIRS) @@ -107,5 +115,6 @@ pub(crate) fn walk_collect_files( Ok(WalkOutput { pairs, ignore_rules, + policy_sources: policy.sources, }) } diff --git a/crates/fff-core/src/watcher/background_watcher.rs b/crates/fff-core/src/watcher/background_watcher.rs index 36a9e124..cf817f58 100644 --- a/crates/fff-core/src/watcher/background_watcher.rs +++ b/crates/fff-core/src/watcher/background_watcher.rs @@ -10,6 +10,7 @@ use notify::event::{AccessKind, AccessMode}; use notify::{Config, EventKind, EventKindMask, RecursiveMode}; use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, NoCache, new_debouncer_opt}; use parking_lot::Mutex; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::mpsc; @@ -22,10 +23,15 @@ type Debouncer = notify_debouncer_full::Debouncer>>, - watch_tx: Option>, + watch_tx: Option>, owner_thread: Option>, } +enum WatchRequest { + Directory(PathBuf), + PolicySourceDirectory(PathBuf), +} + const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50); /// Minimum seconds between frecency tracks of the same file in AI mode. /// Prevents score inflation from rapid burst edits by AI agents. @@ -77,14 +83,15 @@ impl BackgroundWatcher { // spare watcher (configurable by the user, usually 100k - 1m) let use_recursive = cfg!(any(target_os = "macos", target_os = "windows")); - let (watch_tx, watch_rx) = mpsc::channel::(); + let (watch_tx, watch_rx) = mpsc::channel::(); let watch_tx_for_debouncer = watch_tx.clone(); let owner_weak_picker = shared_picker.weaken(); let owner_git_workdir = git_workdir.clone(); let owner_git_worker = Arc::clone(&git_status_worker); - let debouncer = Self::create_debouncer( + let policy_watch_dirs = policy_watch_directories(&shared_picker); + let (debouncer, mut successful_policy_watch_dirs) = Self::create_debouncer( base_path, git_workdir, shared_picker, @@ -93,14 +100,12 @@ impl BackgroundWatcher { use_recursive, watch_tx_for_debouncer, git_status_worker, + &policy_watch_dirs, )?; info!("Background file watcher initialized successfully"); let debouncer = Arc::new(Mutex::new(Some(debouncer))); - // Only the Linux per-dir-watch branch needs this clone; on other - // platforms the owner thread never touches the debouncer. - #[cfg(target_os = "linux")] let owner_debouncer = Arc::clone(&debouncer); let owner_span = trace_span.clone(); @@ -108,7 +113,7 @@ impl BackgroundWatcher { .name("fff-watcher-own".into()) .spawn(move || { let _g = owner_span.enter(); - while let Ok(dir) = watch_rx.recv() { + while let Ok(request) = watch_rx.recv() { // if the picker is dropped we do need to exit the loop let Some(strong_picker) = owner_weak_picker.upgrade() else { break; @@ -122,7 +127,7 @@ impl BackgroundWatcher { // registering a second overlapping stream there produces // duplicate/out-of-order events. #[cfg(target_os = "linux")] - { + if let WatchRequest::Directory(dir) = &request { // Register the new directory with the debouncer, then // drop the mutex BEFORE doing picker-side work — see // the comment on `BackgroundWatcher::stop` for the @@ -132,7 +137,7 @@ impl BackgroundWatcher { break; }; - if let Err(e) = debouncer.watch(&dir, RecursiveMode::NonRecursive) { + if let Err(e) = debouncer.watch(dir, RecursiveMode::NonRecursive) { warn!( ?e, dir = %dir.display(), @@ -141,12 +146,33 @@ impl BackgroundWatcher { } } - track_files_from_new_directories( - &dir, - &strong_picker, - &owner_git_workdir, - &owner_git_worker, - ); + if let WatchRequest::PolicySourceDirectory(dir) = &request { + let mut guard = owner_debouncer.lock(); + let Some(debouncer) = guard.as_mut() else { + break; + }; + + if let Err(error) = try_register_policy_watch( + &mut successful_policy_watch_dirs, + dir, + |path| debouncer.watch(path, RecursiveMode::NonRecursive), + ) { + warn!( + ?error, + path = %dir.display(), + "Failed to watch ignore policy source directory" + ); + } + } + + if let WatchRequest::Directory(dir) = request { + track_files_from_new_directories( + &dir, + &strong_picker, + &owner_git_workdir, + &owner_git_worker, + ); + } // Transient strong ref drops here, back // to weak-only before the next `recv()`. @@ -171,9 +197,10 @@ impl BackgroundWatcher { shared_frecency: SharedFrecency, mode: FFFMode, use_recursive: bool, - watch_tx: mpsc::Sender, + watch_tx: mpsc::Sender, git_status_worker: Arc, - ) -> Result { + policy_watch_dirs: &[PathBuf], + ) -> Result<(Debouncer, HashSet), Error> { let config = Config::default() .with_follow_symlinks(false) // only the actual modification events, ignore the open syscals that we can generate by @@ -206,7 +233,7 @@ impl BackgroundWatcher { // every new directory created has to be reflected in the picker state for dir in new_dirs { - if let Err(e) = watch_tx.send(dir) { + if let Err(e) = watch_tx.send(WatchRequest::Directory(dir)) { error!(?e, "Failed to send directory update error"); } } @@ -284,8 +311,10 @@ impl BackgroundWatcher { // to observe changes that affect git status (staging, unstaging, // committing, branch switches, merges, etc) watch_git_status_paths(&mut debouncer, git_workdir.as_ref()); + let successful_policy_watch_dirs = + watch_policy_source_dirs(&mut debouncer, policy_watch_dirs); - Ok(debouncer) + Ok((debouncer, successful_policy_watch_dirs)) } /// Signal the watcher to shut down without blocking on its worker @@ -304,7 +333,17 @@ impl BackgroundWatcher { pub(crate) fn request_watch_dir(&self, dir: PathBuf) -> bool { match self.watch_tx.as_ref() { - Some(tx) => tx.send(dir).is_ok(), + Some(tx) => tx.send(WatchRequest::Directory(dir)).is_ok(), + None => false, + } + } + + pub(crate) fn request_watch_policy_source_dir(&self, dir: PathBuf) -> bool { + let Some(dir) = closest_existing_directory(&dir) else { + return false; + }; + match self.watch_tx.as_ref() { + Some(tx) => tx.send(WatchRequest::PolicySourceDirectory(dir)).is_ok(), None => false, } } @@ -330,10 +369,14 @@ fn handle_debounced_events( let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok()); // Prefer the walker's own ignore rules (zlob); grab a cheap Arc clone once // per batch so we don't hold the picker lock during filtering. - let walker_rules = shared_picker + let (walker_rules, policy_sources) = shared_picker .read() .ok() - .and_then(|g| g.as_ref().and_then(|p| p.ignore_rules())); + .and_then(|g| { + g.as_ref() + .map(|picker| (picker.ignore_rules(), picker.policy_sources())) + }) + .unwrap_or_default(); let filter = IgnoreFilter::new(base_path, walker_rules, repo.as_ref()); let mut need_full_rescan = false; let mut need_full_git_rescan = false; @@ -383,10 +426,17 @@ fn handle_debounced_events( tracing::debug!(event = ?debounced_event.event, "Processing FS event"); for path in &debounced_event.event.paths { + // A missing policy source is watched through its nearest existing + // ancestor, so creating the next path component must rebuild the + // policy and move the watch closer to the eventual file. + let affects_policy = policy_sources + .iter() + .any(|source| source == path || source.starts_with(path)); if matches!( path.file_name().and_then(|f| f.to_str()), Some(".ignore") | Some(".gitignore") - ) { + ) || affects_policy + { info!( "Detected change in ignore definition file: {}", path.display() @@ -873,6 +923,69 @@ fn watch_git_status_paths(debouncer: &mut Debouncer, git_workdir: Option<&PathBu } } +fn policy_watch_directories(picker: &SharedFilePicker) -> Vec { + let Some(sources) = picker + .read() + .ok() + .and_then(|guard| guard.as_ref().map(|picker| picker.policy_sources())) + else { + return Vec::new(); + }; + + policy_watch_directories_from_sources(&sources) +} + +fn policy_watch_directories_from_sources(sources: &[PathBuf]) -> Vec { + let mut directories = sources + .iter() + .filter_map(|source| source.parent()) + .filter_map(closest_existing_directory) + .collect::>(); + directories.sort_unstable(); + directories.dedup(); + directories +} + +fn closest_existing_directory(path: &Path) -> Option { + // notify rejects missing directories; watching the nearest ancestor keeps + // future source creation observable without retrying a slow failed watch + // ahead of every new-directory injection on the owner FIFO. + path.ancestors() + .find(|candidate| candidate.is_dir()) + .map(Path::to_path_buf) +} + +fn try_register_policy_watch( + successful: &mut HashSet, + dir: &Path, + watch: impl FnOnce(&Path) -> Result<(), E>, +) -> Result { + if successful.contains(dir) { + return Ok(false); + } + + // A failed native watch is not coverage. Keep it retryable so a transient + // permission/resource failure cannot silently freeze ignore policy state. + watch(dir)?; + successful.insert(dir.to_path_buf()); + Ok(true) +} + +fn watch_policy_source_dirs( + debouncer: &mut Debouncer, + directories: &[PathBuf], +) -> HashSet { + let mut successful = HashSet::new(); + for dir in directories { + if let Err(error) = try_register_policy_watch(&mut successful, dir, |path| { + debouncer.watch(path, RecursiveMode::NonRecursive) + }) { + warn!(?error, path = %dir.display(), "Failed to watch ignore policy source directory"); + } + } + successful +} + #[cfg(test)] mod tests { use super::*; @@ -947,6 +1060,162 @@ mod tests { assert_eq!(received[0].kind, WatchEventKind::Modified); } + #[test] + fn policy_source_change_broadcasts_rescan() { + let tmp = tempfile::tempdir().unwrap(); + let base = crate::path_utils::canonicalize(tmp.path()).unwrap(); + let repo = git2::Repository::init(&base).unwrap(); + let global = base.join("global-ignore"); + std::fs::write(&global, "*.tmp\n").unwrap(); + repo.config() + .unwrap() + .set_str("core.excludesFile", global.to_str().unwrap()) + .unwrap(); + + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::noop(); + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + shared_picker.rebase_watches(&base); + *shared_picker.write().unwrap() = Some(picker); + + let (sender, receiver) = mpsc::channel::>(); + shared_picker + .watch_registry() + .subscribe( + &base, + "**", + WatchOptions::default(), + Box::new(move |_, events| sender.send(events.to_vec()).unwrap()), + ) + .unwrap(); + + std::fs::write(&global, "*.log\n").unwrap(); + let events = vec![DebouncedEvent::new( + Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))).add_path(global), + Instant::now(), + )]; + handle_debounced_events( + FFFMode::Neovim, + events, + &base, + &Some(base.clone()), + &shared_picker, + &shared_frecency, + &GitStatusWorker::new(), + ); + + let delivered = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].kind, WatchEventKind::Rescan); + assert_eq!(delivered[0].path, base); + } + + #[test] + fn missing_policy_parents_share_the_nearest_existing_watch() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + std::fs::create_dir(&config).unwrap(); + let sources = vec![ + config.join("git/ignore"), + config.join("git/config"), + config.join("other/missing"), + ]; + + assert_eq!( + policy_watch_directories_from_sources(&sources), + vec![config] + ); + } + + #[test] + fn failed_policy_watch_remains_retryable_until_success() { + let dir = PathBuf::from("/policy"); + let mut successful = HashSet::new(); + let mut attempts = 0; + + let failed = try_register_policy_watch(&mut successful, &dir, |_| { + attempts += 1; + Err("transient failure") + }); + assert_eq!(failed, Err("transient failure")); + assert!(successful.is_empty()); + + let retried = try_register_policy_watch(&mut successful, &dir, |_| { + attempts += 1; + Ok::<_, &str>(()) + }); + assert_eq!(retried, Ok(true)); + assert_eq!(attempts, 2); + + let deduplicated = try_register_policy_watch(&mut successful, &dir, |_| { + attempts += 1; + Ok::<_, &str>(()) + }); + assert_eq!(deduplicated, Ok(false)); + assert_eq!(attempts, 2); + } + + #[test] + fn policy_source_ancestor_creation_broadcasts_rescan() { + let tmp = tempfile::tempdir().unwrap(); + let base = crate::path_utils::canonicalize(tmp.path()).unwrap(); + let repo = git2::Repository::init(&base).unwrap(); + let global = base.join("future/config/git/ignore"); + repo.config() + .unwrap() + .set_str("core.excludesFile", global.to_str().unwrap()) + .unwrap(); + + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::noop(); + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + shared_picker.rebase_watches(&base); + *shared_picker.write().unwrap() = Some(picker); + + let (sender, receiver) = mpsc::channel::>(); + shared_picker + .watch_registry() + .subscribe( + &base, + "**", + WatchOptions::default(), + Box::new(move |_, events| sender.send(events.to_vec()).unwrap()), + ) + .unwrap(); + + let created_ancestor = base.join("future"); + let events = vec![DebouncedEvent::new( + Event::new(EventKind::Create(CreateKind::Folder)).add_path(created_ancestor), + Instant::now(), + )]; + handle_debounced_events( + FFFMode::Neovim, + events, + &base, + &Some(base.clone()), + &shared_picker, + &shared_frecency, + &GitStatusWorker::new(), + ); + + let delivered = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].kind, WatchEventKind::Rescan); + assert_eq!(delivered[0].path, base); + } + #[test] fn dotgit_status_filter_matches_worktree_state_changes() { let tmp = tempfile::tempdir().unwrap(); diff --git a/packages/fff-node/test/ignore-policy.mjs b/packages/fff-node/test/ignore-policy.mjs new file mode 100644 index 00000000..90eaf3ea --- /dev/null +++ b/packages/fff-node/test/ignore-policy.mjs @@ -0,0 +1,94 @@ +import { strict as assert } from "node:assert"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { FileFinder } from "../dist/src/index.js"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = predicate(); + if (value) return value; + await sleep(50); + } + return predicate(); +} + +let repoDir = ""; +let globalIgnore = ""; +let finder = null; + +function indexedPaths() { + const result = finder.fileSearch("", { pageSize: 100 }); + assert.ok(result.ok, `search failed: ${!result.ok ? result.error : ""}`); + return new Set(result.value.items.map((item) => item.relativePath)); +} + +describe("fff-node Git ignore policy", { concurrency: 1 }, () => { + before(async () => { + repoDir = mkdtempSync(join(tmpdir(), "fff-ignore-policy-")); + globalIgnore = join(tmpdir(), `fff-global-ignore-${process.pid}`); + execFileSync("git", ["init", "--quiet", repoDir]); + execFileSync("git", ["-C", repoDir, "config", "core.excludesFile", globalIgnore]); + + mkdirSync(join(repoDir, "nested")); + mkdirSync(join(repoDir, ".git", "info"), { recursive: true }); + writeFileSync(globalIgnore, "*.tmp\n"); + writeFileSync(join(repoDir, ".git", "info", "exclude"), "info-only.txt\n"); + writeFileSync(join(repoDir, ".gitignore"), "!kept.tmp\nnested/*.log\n"); + writeFileSync(join(repoDir, "global.tmp"), "ignored by the global policy\n"); + writeFileSync(join(repoDir, "kept.tmp"), "root negation wins\n"); + writeFileSync(join(repoDir, "info-only.txt"), "ignored by info/exclude\n"); + writeFileSync(join(repoDir, "visible.md"), "visible\n"); + writeFileSync(join(repoDir, "nested", "ignored.log"), "ignored by root\n"); + + const result = FileFinder.create({ basePath: repoDir }); + assert.ok(result.ok, `create failed: ${!result.ok ? result.error : ""}`); + finder = result.value; + const scanned = await finder.waitForScan(10_000); + assert.ok(scanned.ok && scanned.value, "initial scan should finish"); + const watcherReady = await waitFor(() => { + const progress = finder.getScanProgress(); + return progress.ok && progress.value.isWatcherReady; + }); + assert.ok(watcherReady, "watcher should become ready"); + }); + + after(() => { + if (finder && !finder.isDestroyed) finder.destroy(); + if (repoDir) rmSync(repoDir, { recursive: true, force: true }); + if (globalIgnore) rmSync(globalIgnore, { force: true }); + }); + + it("applies global, info, root, and nested precedence in the Node binding", () => { + const paths = indexedPaths(); + assert.ok(paths.has("kept.tmp")); + assert.ok(paths.has("visible.md")); + assert.ok(!paths.has("global.tmp")); + assert.ok(!paths.has("info-only.txt")); + assert.ok(!paths.has("nested/ignored.log")); + }); + + it("rescans when an external policy source changes", async () => { + const events = []; + const subscription = finder.watch(repoDir, (batch) => events.push(...batch)); + assert.ok(subscription.ok, `watch failed: ${!subscription.ok ? subscription.error : ""}`); + + writeFileSync(globalIgnore, "*.bak\n"); + const rescan = await waitFor(() => events.some((event) => event.kind === "rescan")); + assert.ok(rescan, `expected rescan event, got ${JSON.stringify(events)}`); + + const policyApplied = await waitFor(() => indexedPaths().has("global.tmp")); + assert.ok(policyApplied, "the rebuilt index should use the changed global policy"); + subscription.value(); + }); +}); diff --git a/packages/fff-node/test/non-git-ignore-policy.mjs b/packages/fff-node/test/non-git-ignore-policy.mjs new file mode 100644 index 00000000..e080f776 --- /dev/null +++ b/packages/fff-node/test/non-git-ignore-policy.mjs @@ -0,0 +1,111 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const scenario = process.env.FFF_NON_GIT_IGNORE_SCENARIO; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = predicate(); + if (value) return value; + await sleep(50); + } + return predicate(); +} + +async function runScenario(kind) { + const fixture = mkdtempSync(join(tmpdir(), `fff-non-git-${kind}-`)); + const vault = join(fixture, "vault"); + const configHome = join(fixture, "config"); + const home = join(fixture, "home"); + const gitConfigDir = join(configHome, "git"); + const defaultIgnore = join(gitConfigDir, "ignore"); + const configuredIgnore = join(fixture, "configured-ignore"); + const policySource = kind === "configured" ? configuredIgnore : defaultIgnore; + + mkdirSync(vault); + mkdirSync(home); + mkdirSync(gitConfigDir, { recursive: true }); + if (kind === "configured") { + writeFileSync( + join(gitConfigDir, "config"), + `[core]\n\texcludesFile = ${configuredIgnore}\n`, + ); + } + writeFileSync(policySource, "*.tmp\n"); + writeFileSync(join(vault, "global.tmp"), "ignored\n"); + writeFileSync(join(vault, "visible.md"), "visible\n"); + + process.env.XDG_CONFIG_HOME = configHome; + process.env.HOME = home; + + const { FileFinder } = await import("../dist/src/index.js"); + let finder = null; + try { + const created = FileFinder.create({ basePath: vault }); + assert.ok(created.ok, `create failed: ${!created.ok ? created.error : ""}`); + finder = created.value; + const scanned = await finder.waitForScan(10_000); + assert.ok(scanned.ok && scanned.value, "initial scan should finish"); + const watcherReady = await waitFor(() => { + const progress = finder.getScanProgress(); + return progress.ok && progress.value.isWatcherReady; + }); + assert.ok(watcherReady, "watcher should become ready"); + + const initial = finder.fileSearch("", { pageSize: 100 }); + assert.ok(initial.ok); + const initialPaths = new Set(initial.value.items.map((item) => item.relativePath)); + assert.ok(!initialPaths.has("global.tmp"), `${kind} policy was not applied`); + assert.ok(initialPaths.has("visible.md")); + + const events = []; + const subscription = finder.watch(vault, (batch) => events.push(...batch)); + assert.ok(subscription.ok, `watch failed: ${!subscription.ok ? subscription.error : ""}`); + writeFileSync(policySource, "*.bak\n"); + + const rescanned = await waitFor(() => events.some((event) => event.kind === "rescan")); + assert.ok(rescanned, `${kind} policy change did not emit rescan`); + const applied = await waitFor(() => { + const result = finder.fileSearch("", { pageSize: 100 }); + return result.ok && result.value.items.some((item) => item.relativePath === "global.tmp"); + }); + assert.ok(applied, `${kind} policy change did not rebuild the index`); + subscription.value(); + } finally { + if (finder && !finder.isDestroyed) finder.destroy(); + rmSync(fixture, { recursive: true, force: true }); + } +} + +if (scenario) { + await runScenario(scenario); +} else { + describe("fff-node non-Git vault ignore policy", { concurrency: 1 }, () => { + for (const kind of ["configured", "default"]) { + it(`applies and watches ${kind} user excludes`, () => { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], { + env: { ...process.env, FFF_NON_GIT_IGNORE_SCENARIO: kind }, + encoding: "utf8", + timeout: 20_000, + }); + assert.equal( + result.status, + 0, + `child failed (${result.signal ?? "no signal"}):\n${result.stdout}\n${result.stderr}`, + ); + }); + } + }); +}