diff --git a/README.md b/README.md index 5394b3660..914dfe949 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,8 @@ require('fff').setup({ prompt_vim_mode = false, wrap_around = false, -- true to wrap the cursor around when moving past the first/last item follow_symlinks = false, + -- Extra .gitignore-syntax lines, see "File filtering". `!pattern` force-includes ignored paths + extra_ignore = {}, -- Allow indexing the user's $HOME directory. Enabled by default. -- Disable if you strictly sure you don't want this, as it makes whole fff error hard enable_home_dir_scanning = true, @@ -586,6 +588,23 @@ FFF honours `.gitignore`. For picker-only ignores that do not touch git, add a s docs/archive/**/*.md ``` +To keep the rules out of the repo, use `extra_ignore`. It takes `.gitignore`-syntax lines relative to the indexed +root and takes precedence over every ignore file, so `!pattern` makes gitignored files searchable: + +```lua +extra_ignore = { + '!.env*', -- gitignored env files, at any depth + '!logs/', -- a directory ignored by a `logs/` rule + '!nvim/', '!nvim/**', -- dotfiles repos ignoring everything with `*` need both lines + 'fixtures/', -- plain lines exclude more +}, +``` + +Plain git semantics apply: a file inside an ignored directory is only reachable once the directory itself is included. +Included files keep the `ignored` git status. The same option is `extraIgnore` in the Node/Bun SDKs, `extra_ignore` in +Python and the C `FffCreateOptions` (newline-separated), and a repeatable `--extra-ignore` flag in fff-mcp. It needs the +zlob walker that release builds use; the pure-Rust walker logs a warning and skips it. + Run `:FFFScan` to force a rescan. ### Troubleshooting @@ -772,6 +791,7 @@ FffResult *res = fff_create_instance_with(&(FffCreateOptions){ .watch = true, .enable_fs_root_scanning = false, // off by default .enable_home_dir_scanning = false, // off by default + .extra_ignore = "!.env*\nfixtures/", // newline-separated .gitignore lines (v3) }); ``` diff --git a/crates/fff-c/include/fff.h b/crates/fff-c/include/fff.h index 2574cfb45..493e0afd0 100644 --- a/crates/fff-c/include/fff.h +++ b/crates/fff-c/include/fff.h @@ -12,7 +12,7 @@ /** * Current used version of [`FffCreateOptions`]. */ -#define FFF_CREATE_OPTIONS_VERSION 2 +#define FFF_CREATE_OPTIONS_VERSION 3 /** * Current version of [`FffWatchOptions`]. @@ -139,6 +139,11 @@ typedef struct FffCreateOptions { * external loop protection cyclic symlinks can wedge the watcher. */ bool follow_symlinks; + /** + * Newline-separated `.gitignore`-syntax lines relative to `base_path`, + * taking precedence over every ignore file (`!pattern` force-includes). NULL/empty for none. + */ + const char *extra_ignore; } FffCreateOptions; /** diff --git a/crates/fff-c/src/ffi_types.rs b/crates/fff-c/src/ffi_types.rs index 3a007624f..1fb3b958f 100644 --- a/crates/fff-c/src/ffi_types.rs +++ b/crates/fff-c/src/ffi_types.rs @@ -12,7 +12,7 @@ use fff::{ }; /// Current used version of [`FffCreateOptions`]. -pub const FFF_CREATE_OPTIONS_VERSION: u32 = 2; +pub const FFF_CREATE_OPTIONS_VERSION: u32 = 3; /// Options for `fff_create_instance_with`. /// @@ -57,7 +57,11 @@ pub struct FffCreateOptions { /// Follow symlinks during scan and watcher walks. Off by default: without /// external loop protection cyclic symlinks can wedge the watcher. pub follow_symlinks: bool, - // ----- new version 3+ fields go here, ALWAYS appended ----- + // ----- v3 fields ----- + /// Newline-separated `.gitignore`-syntax lines relative to `base_path`, + /// taking precedence over every ignore file (`!pattern` force-includes). NULL/empty for none. + pub extra_ignore: *const c_char, + // ----- new version 4+ fields go here, ALWAYS appended ----- } impl FffCreateOptions { @@ -80,6 +84,7 @@ impl FffCreateOptions { enable_fs_root_scanning: false, enable_home_dir_scanning: false, follow_symlinks: false, + extra_ignore: ptr::null(), } } } @@ -786,7 +791,8 @@ mod options_layout_tests { #[test] #[cfg(target_pointer_width = "64")] fn fff_create_options_layout_is_stable_64bit() { - assert_eq!(size_of::(), 88); + // v1/v2 occupied 88 bytes; the v3 pointer is appended at offset 88. + assert_eq!(size_of::(), 96); assert_eq!(align_of::(), 8); assert_eq!(offset_of!(FffCreateOptions, version), 0); @@ -805,5 +811,6 @@ mod options_layout_tests { assert_eq!(offset_of!(FffCreateOptions, enable_fs_root_scanning), 80); assert_eq!(offset_of!(FffCreateOptions, enable_home_dir_scanning), 81); assert_eq!(offset_of!(FffCreateOptions, follow_symlinks), 82); + assert_eq!(offset_of!(FffCreateOptions, extra_ignore), 88); } } diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 76c64b6f5..88c6c1047 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -253,6 +253,16 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions) opts.cache_budget_max_file_size, ); + let extra_ignore = if opts.version >= 3 && !opts.extra_ignore.is_null() { + // Fail loudly: silently dropping the rules would index paths the caller excluded. + match unsafe { cstr_to_str(opts.extra_ignore) } { + Some(s) => s.lines().map(String::from).collect(), + None => return FffResult::err("opts.extra_ignore is not valid UTF-8"), + } + } else { + Vec::new() + }; + if let Err(e) = FilePicker::new_with_shared_state( shared_picker.clone(), shared_frecency.clone(), @@ -267,6 +277,7 @@ pub unsafe extern "C" fn fff_create_instance_with(opts: *const FffCreateOptions) enable_fs_root_scanning: opts.enable_fs_root_scanning, enable_home_dir_scanning: opts.enable_home_dir_scanning, git_recency: Default::default(), + extra_ignore, }, ) { return FffResult::err(&format!("Failed to init file picker: {}", e)); @@ -1020,6 +1031,10 @@ pub unsafe extern "C" fn fff_restart_index( .as_ref() .map(|p| p.git_recency_config()) .unwrap_or_default(); + let extra_ignore = guard + .as_ref() + .map(|p| p.extra_ignore().to_vec()) + .unwrap_or_default(); drop(guard); @@ -1037,6 +1052,7 @@ pub unsafe extern "C" fn fff_restart_index( enable_fs_root_scanning: fs_root, enable_home_dir_scanning: home_dir, git_recency, + extra_ignore, }, ) { Ok(()) => FffResult::ok_empty(), diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index 2f5911501..469491c7d 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -567,6 +567,9 @@ pub struct FilePickerOptions { /// Ranking boost for files that participated in recent commits of the /// current branch. Enabled with default limits unless overridden. pub git_recency: GitRecencyConfig, + /// Extra `.gitignore`-syntax lines, relative to `base_path`, taking precedence + /// over every ignore file. `!pattern` force-includes ignored paths (zlob walker only). + pub extra_ignore: Vec, } impl Default for FilePickerOptions { @@ -582,6 +585,7 @@ impl Default for FilePickerOptions { enable_fs_root_scanning: false, enable_home_dir_scanning: false, git_recency: GitRecencyConfig::default(), + extra_ignore: Vec::new(), } } } @@ -606,6 +610,7 @@ pub struct FilePicker { enable_fs_root_scanning: bool, enable_home_dir_scanning: bool, git_recency_config: GitRecencyConfig, + extra_ignore: Arc<[String]>, trace_span: tracing::Span, trace_id: String, } @@ -693,6 +698,21 @@ impl FilePicker { self.git_recency_config } + pub fn extra_ignore(&self) -> &[String] { + &self.extra_ignore + } + + pub(crate) fn extra_ignore_shared(&self) -> Arc<[String]> { + Arc::clone(&self.extra_ignore) + } + + /// Whether `extra_ignore` can surface gitignored files, which then need the ignored git status. + pub(crate) fn reincludes_ignored(&self) -> bool { + self.extra_ignore + .iter() + .any(|line| line.trim_start().starts_with('!')) + } + pub fn trace_id(&self) -> &str { &self.trace_id } @@ -929,6 +949,7 @@ impl FilePicker { enable_fs_root_scanning: options.enable_fs_root_scanning, enable_home_dir_scanning: options.enable_home_dir_scanning, git_recency_config: options.git_recency, + extra_ignore: options.extra_ignore.into(), trace_span, trace_id, }) @@ -958,6 +979,7 @@ impl FilePicker { let follow_symlinks = picker.follow_symlinks; let enable_fs_root_scanning = picker.enable_fs_root_scanning; let enable_home_dir_scanning = picker.enable_home_dir_scanning; + let extra_ignore = picker.extra_ignore_shared(); let signals = picker.scan_signals(); let scanned_files_counter = picker.scanned_files_counter(); @@ -991,6 +1013,7 @@ impl FilePicker { signals, scanned_files_counter, trace_span, + extra_ignore, ScanConfig { warmup, content_indexing, @@ -1020,7 +1043,10 @@ impl FilePicker { self.scanned_files_count.store(0, Ordering::Relaxed); let git_workdir = FileSync::discover_git_workdir(&self.base_path); - let git_handle = git_workdir.clone().map(FileSync::spawn_git_status); + let include_ignored = self.reincludes_ignored(); + let git_handle = git_workdir + .clone() + .map(|workdir| FileSync::spawn_git_status(workdir, include_ignored)); let empty_frecency = SharedFrecency::default(); let sync = FileSync::walk_filesystem( @@ -1030,6 +1056,7 @@ impl FilePicker { &empty_frecency, self.mode, self.follow_symlinks, + &self.extra_ignore, )?; self.sync_data = sync; @@ -1042,17 +1069,21 @@ impl FilePicker { } if let Some(handle) = git_handle - && let Ok(Some(git_cache)) = handle.join() + && let Ok(Some(mut git_cache)) = handle.join() { - let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; + // Ignored dirs first so explicit per-file statuses win, as in `update_git_statuses`. + self.mark_files_in_ignored_dirs(&git_cache.take_ignored_dirs()); + let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE]; let arena = self.arena_base_ptr(); for file in self.sync_data.files.iter_mut() { - file.git_status = git_cache.lookup_status(file.write_absolute_path( + if let Some(status) = git_cache.lookup_status(file.write_absolute_path( arena, &self.base_path, &mut path_buf, - )); + )) { + file.git_status = Some(status); + } } } @@ -1531,13 +1562,14 @@ impl FilePicker { /// Update git statuses for files, using the provided shared frecency tracker. pub(crate) fn update_git_statuses( &mut self, - status_cache: GitStatusCache, + mut status_cache: GitStatusCache, shared_frecency: &SharedFrecency, ) -> Result<(), Error> { debug!( statuses_count = status_cache.statuses_len(), "Updating git status", ); + self.mark_files_in_ignored_dirs(&status_cache.take_ignored_dirs()); let mode = self.mode; let bp = self.base_path.clone(); @@ -1571,6 +1603,58 @@ impl FilePicker { Ok(()) } + // libgit2 reports an ignored dir once without recursing, so its indexed + // (force-included) descendants get the ignored status here. + fn mark_files_in_ignored_dirs(&mut self, ignored_dirs: &[PathBuf]) { + if ignored_dirs.is_empty() { + return; + } + if ignored_dirs + .iter() + .any(|dir| self.base_path.starts_with(dir)) + { + for file in self.sync_data.files.iter_mut() { + file.git_status = Some(git2::Status::IGNORED); + } + return; + } + + let prefixes: ahash::AHashSet = ignored_dirs + .iter() + .filter_map(|dir| dir.strip_prefix(&self.base_path).ok()) + .map(|rel| { + let mut rel = + crate::path_utils::to_canonical_slashes(&rel.to_string_lossy()).into_owned(); + rel.push('/'); + rel + }) + .collect(); + if prefixes.is_empty() { + return; + } + + let in_ignored_dir: Vec = self + .sync_data + .dirs + .iter() + .map(|dir| { + let path = dir.relative_path(&*self); + path.match_indices('/') + .any(|(i, _)| prefixes.contains(&path[..=i])) + }) + .collect(); + + for file in self.sync_data.files.iter_mut() { + if in_ignored_dir + .get(file.parent_dir_index as usize) + .copied() + .unwrap_or(false) + { + file.git_status = Some(git2::Status::IGNORED); + } + } + } + // Replaces every recency score with a freshly computed set. `None` zeroes // them, so a vanished window (orphan HEAD, repo gone) leaves no stale boost. pub(crate) fn apply_git_recency(&mut self, scores: Option<&AHashMap>) { @@ -2061,11 +2145,14 @@ impl FileSync { git_workdir } - pub(crate) fn spawn_git_status(git_workdir: PathBuf) -> JoinHandle> { + pub(crate) fn spawn_git_status( + git_workdir: PathBuf, + include_ignored: bool, + ) -> JoinHandle> { std::thread::spawn(move || { GitStatusCache::read_git_status( Some(git_workdir.as_path()), - &mut crate::git::initial_scan_status_options(), + &mut crate::git::initial_scan_status_options(include_ignored), ) }) } @@ -2081,6 +2168,7 @@ impl FileSync { shared_frecency: &SharedFrecency, mode: FFFMode, follow_symlinks: bool, + extra_ignore: &[String], ) -> Result { let scan_start = std::time::Instant::now(); info!("SCAN: Starting filesystem walk and git status (async)"); @@ -2097,6 +2185,7 @@ impl FileSync { base_path, is_git_repo, follow_symlinks, + extra_ignore, bg_threads, synced_files_count, )?; diff --git a/crates/fff-core/src/git.rs b/crates/fff-core/src/git.rs index f75091b4e..e9d54a7c9 100644 --- a/crates/fff-core/src/git.rs +++ b/crates/fff-core/src/git.rs @@ -21,12 +21,13 @@ pub(crate) fn tune_libgit2_for_local_reads() { }); } -pub(crate) fn default_status_options() -> StatusOptions { +pub(crate) fn default_status_options(include_ignored: bool) -> StatusOptions { let mut opts = StatusOptions::new(); opts.include_untracked(true) .recurse_untracked_dirs(true) .include_unmodified(true) .exclude_submodules(true); + with_ignored(&mut opts, include_ignored); opts } @@ -36,34 +37,50 @@ pub(crate) fn default_status_options() -> StatusOptions { /// `git_status: None` (== clean), so a missing cache entry already means /// "clean" — no need to ask libgit2 to enumerate every tracked path. /// Saves seconds on huge dirty trees (e.g. chromium with 400k+ entries). -pub(crate) fn initial_scan_status_options() -> StatusOptions { +pub(crate) fn initial_scan_status_options(include_ignored: bool) -> StatusOptions { let mut opts = StatusOptions::new(); opts.include_untracked(true) .recurse_untracked_dirs(true) .exclude_submodules(true); + with_ignored(&mut opts, include_ignored); opts } #[derive(Debug, Clone, Default)] -pub(crate) struct GitStatusCache(AHashMap); +pub(crate) struct GitStatusCache { + statuses: AHashMap, + // Ignored dirs reported without recursion (absolute, no trailing slash). + ignored_dirs: Vec, +} impl IntoIterator for GitStatusCache { type Item = (PathBuf, Status); type IntoIter = as IntoIterator>::IntoIter; fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() + self.statuses.into_iter() } } impl GitStatusCache { + fn from_statuses(statuses: AHashMap) -> Self { + Self { + statuses, + ignored_dirs: Vec::new(), + } + } + pub fn statuses_len(&self) -> usize { - self.0.len() + self.statuses.len() } #[inline] pub fn lookup_status(&self, full_path: &Path) -> Option { - self.0.get(full_path).copied() + self.statuses.get(full_path).copied() + } + + pub(crate) fn take_ignored_dirs(&mut self) -> Vec { + std::mem::take(&mut self.ignored_dirs) } #[tracing::instrument(skip(repo, status_options))] @@ -73,22 +90,31 @@ impl GitStatusCache { ) -> Result { let statuses = repo.statuses(Some(status_options))?; let Some(repo_path) = repo.workdir() else { - return Ok(Self(AHashMap::new())); // repo is bare + return Ok(Self::default()); // repo is bare }; let repo_path = crate::path_utils::normalize(repo_path.to_path_buf()); - let mut entries = AHashMap::with_capacity(statuses.len()); + let mut cache = Self::from_statuses(AHashMap::with_capacity(statuses.len())); for entry in &statuses { if let Ok(entry_path) = entry.path() { + let status = entry.status(); + if let Some(dir) = entry_path.strip_suffix('/') + && status.contains(Status::IGNORED) + { + let dir = crate::path_utils::normalize(repo_path.join(dir)); + cache.ignored_dirs.push(dir); + continue; + } + // libgit2 returns entry paths with forward slashes on every platform // fff stores native paths - meaning we have forward slash issue on windows let full_path = crate::path_utils::normalize(repo_path.join(entry_path)); - entries.insert(full_path, entry.status()); + cache.statuses.insert(full_path, status); } } - Ok(Self(entries)) + Ok(cache) } pub fn read_git_status( @@ -114,13 +140,14 @@ impl GitStatusCache { pub fn git_status_for_paths + Debug>( repo: &Repository, paths: &[TPath], + include_ignored: bool, ) -> Result { if paths.is_empty() { - return Ok(Self(AHashMap::new())); + return Ok(Self::default()); } let Some(workdir) = repo.workdir() else { - return Ok(Self(AHashMap::new())); + return Ok(Self::default()); }; let workdir = crate::path_utils::normalize(workdir.to_path_buf()); @@ -133,10 +160,10 @@ impl GitStatusCache { let mut map = AHashMap::with_capacity(1); map.insert(full_path.to_path_buf(), status); - return Ok(Self(map)); + return Ok(Self::from_statuses(map)); } - let mut status_options = default_status_options(); + let mut status_options = default_status_options(include_ignored); for path in paths { status_options.pathspec(path.as_ref().strip_prefix(&workdir)?); } @@ -192,6 +219,13 @@ pub fn format_git_status(status: Option) -> &'static str { format_git_status_opt(status).unwrap_or("unknown") } +// Ignored dirs are reported once without recursion, keeping the extra cost small. +fn with_ignored(opts: &mut StatusOptions, include_ignored: bool) { + if include_ignored { + opts.include_ignored(true).recurse_ignored_dirs(false); + } +} + #[cfg(test)] mod tests { use super::*; @@ -283,7 +317,7 @@ mod tests { let repo = Repository::open(&base).unwrap(); let paths: Vec = names.iter().map(|n| base.join(n)).collect(); - let cache = GitStatusCache::git_status_for_paths(&repo, &paths).unwrap(); + let cache = GitStatusCache::git_status_for_paths(&repo, &paths, false).unwrap(); for (n, abs) in names.iter().zip(paths.iter()) { let status = cache.lookup_status(abs); diff --git a/crates/fff-core/src/scan.rs b/crates/fff-core/src/scan.rs index ee5f6a7ec..24d64ce46 100644 --- a/crates/fff-core/src/scan.rs +++ b/crates/fff-core/src/scan.rs @@ -54,6 +54,7 @@ pub(crate) struct ScanJob { mode: FFFMode, signals: ScanSignals, config: ScanConfig, + extra_ignore: Arc<[String]>, /// Walker-maintained counter backing `get_scan_progress` on the UI /// side. Reset to 0 at scan start, incremented per-file by the /// walker. Shared `Arc` so the UI polls the same atomic. @@ -83,6 +84,7 @@ impl ScanJob { let scanned_files_counter = picker.scanned_files_counter(); let base_path = picker.base_path().to_path_buf(); let trace_span = picker.trace_span(); + let extra_ignore = picker.extra_ignore_shared(); let new_scan_config = ScanConfig { warmup: picker.has_mmap_cache(), @@ -103,6 +105,7 @@ impl ScanJob { base_path, scanned_files_counter, config: new_scan_config, + extra_ignore, shared_picker: shared_picker.clone(), shared_frecency: shared_frecency.clone(), trace_span, @@ -118,6 +121,7 @@ impl ScanJob { signals: ScanSignals, scanned_files_counter: Arc, trace_span: tracing::Span, + extra_ignore: Arc<[String]>, config: ScanConfig, ) -> Self { Self { @@ -128,6 +132,7 @@ impl ScanJob { signals, scanned_files_counter, config, + extra_ignore, trace_span, } } @@ -158,6 +163,7 @@ impl ScanJob { signals, scanned_files_counter, config, + extra_ignore, trace_span: _, } = self; @@ -173,6 +179,7 @@ impl ScanJob { &shared_frecency, mode, config.follow_symlinks, + &extra_ignore, ) { Ok(sync) => sync, Err(e) => { diff --git a/crates/fff-core/src/shared.rs b/crates/fff-core/src/shared.rs index 8c6f1f1fe..871f7997c 100644 --- a/crates/fff-core/src/shared.rs +++ b/crates/fff-core/src/shared.rs @@ -350,7 +350,7 @@ impl SharedFilePicker { /// Refresh git statuses for all indexed files #[tracing::instrument(level = "info", skip_all)] pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result { - let (git_root, recency_config, base_path, picker_id) = { + let (git_root, recency_config, base_path, picker_id, include_ignored) = { // we do the libgit2 off lock cause it might take quite some time on very large repos let guard = self.read()?; let Some(ref picker) = *guard else { @@ -361,6 +361,7 @@ impl SharedFilePicker { picker.git_recency_config(), picker.base_path().to_path_buf(), picker.trace_id().to_owned(), + picker.reincludes_ignored(), ) }; @@ -372,9 +373,12 @@ impl SharedFilePicker { }); let git_status = repo.as_ref().and_then(|repo| { - GitStatusCache::read_status(repo, &mut crate::git::default_status_options()) - .inspect_err(|e| tracing::error!(?e, "Failed to read git status")) - .ok() + GitStatusCache::read_status( + repo, + &mut crate::git::default_status_options(include_ignored), + ) + .inspect_err(|e| tracing::error!(?e, "Failed to read git status")) + .ok() }); let recency = repo @@ -412,12 +416,15 @@ impl SharedFilePicker { return Ok(()); } - let git_root = { + let (git_root, include_ignored) = { let guard = self.read()?; let Some(ref picker) = *guard else { return Err(Error::FilePickerMissing); }; - picker.git_root().map(|p| p.to_path_buf()) + ( + picker.git_root().map(|p| p.to_path_buf()), + picker.reincludes_ignored(), + ) }; let Some(git_root) = git_root else { return Ok(()); @@ -426,7 +433,7 @@ impl SharedFilePicker { wait_for_git_index_lock_release(&git_root); let repo = Repository::open(&git_root)?; - let status = GitStatusCache::git_status_for_paths(&repo, paths)?; + let status = GitStatusCache::git_status_for_paths(&repo, paths, include_ignored)?; let mut guard = self.write()?; let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?; diff --git a/crates/fff-core/src/walk/mod.rs b/crates/fff-core/src/walk/mod.rs index e163d3a61..669b4d88d 100644 --- a/crates/fff-core/src/walk/mod.rs +++ b/crates/fff-core/src/walk/mod.rs @@ -93,7 +93,7 @@ mod tests { fs::write(root.join("target/out.bin"), "bin").unwrap(); let counter = Arc::new(AtomicUsize::new(0)); - let out = walk_collect_files(root, true, false, 1, &counter).unwrap(); + let out = walk_collect_files(root, true, false, &[], 1, &counter).unwrap(); let mut names: Vec = out.pairs.into_iter().map(|(_, rel)| rel).collect(); names.sort(); @@ -117,7 +117,7 @@ mod tests { fs::write(root.join("index.js"), "x").unwrap(); let counter = Arc::new(AtomicUsize::new(0)); - let out = walk_collect_files(root, false, false, 1, &counter).unwrap(); + let out = walk_collect_files(root, false, false, &[], 1, &counter).unwrap(); let names: Vec = out.pairs.into_iter().map(|(_, rel)| rel).collect(); assert!(names.iter().any(|n| n.ends_with("index.js"))); @@ -138,7 +138,7 @@ mod tests { fs::write(root.join("Cargo.toml"), "x").unwrap(); let counter = Arc::new(AtomicUsize::new(0)); - let out = walk_collect_files(root, true, false, 1, &counter).unwrap(); + let out = walk_collect_files(root, true, false, &[], 1, &counter).unwrap(); let rules = out.ignore_rules.expect("zlob surfaces ignore rules"); assert!(rules.is_ignored(Path::new("target/"))); diff --git a/crates/fff-core/src/walk/ripgrep.rs b/crates/fff-core/src/walk/ripgrep.rs index 3f3ca1bc5..bdc66a9a3 100644 --- a/crates/fff-core/src/walk/ripgrep.rs +++ b/crates/fff-core/src/walk/ripgrep.rs @@ -14,9 +14,14 @@ pub(crate) fn walk_collect_files( base_path: &Path, is_git_repo: bool, follow_symlinks: bool, + extra_ignore: &[String], threads: usize, synced_files_count: &Arc, ) -> crate::Result { + if !extra_ignore.is_empty() { + tracing::warn!("extra_ignore is only supported by the zlob walker; ignoring it"); + } + 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 diff --git a/crates/fff-core/src/walk/zlob.rs b/crates/fff-core/src/walk/zlob.rs index 98eddf1fc..ab6108704 100644 --- a/crates/fff-core/src/walk/zlob.rs +++ b/crates/fff-core/src/walk/zlob.rs @@ -15,6 +15,7 @@ pub(crate) fn walk_collect_files( base_path: &Path, is_git_repo: bool, follow_symlinks: bool, + extra_ignore: &[String], threads: usize, synced_files_count: &Arc, ) -> crate::Result { @@ -36,13 +37,23 @@ pub(crate) fn walk_collect_files( // Bulk-fetch the only metadata FileItem needs; zlob never stats more. .metadata(WalkMetadata::SIZE | WalkMetadata::MTIME); - if !is_git_repo - && !IGNORED_DIRS.is_empty() - && let Err(e) = builder.extra_ignore(IGNORED_DIRS) + // User lines go last so they win over the non-git defaults (last match wins). + let mut ignore_lines: Vec<&str> = Vec::new(); + if !is_git_repo { + ignore_lines.extend_from_slice(IGNORED_DIRS); + } + // zlob rejects the whole list on an interior NUL, so drop such lines up front. + ignore_lines.extend(extra_ignore.iter().map(String::as_str).filter(|line| { + let valid = !line.contains('\0'); + if !valid { + tracing::warn!(?line, "skipping extra_ignore line containing NUL"); + } + valid + })); + + if !ignore_lines.is_empty() + && let Err(e) = builder.extra_ignore(&ignore_lines) { - // Interior NUL in one of the extra_ignore patterns would fail - // here — treat as if no extras were supplied rather than - // aborting the whole walk. tracing::warn!(?e, "zlob extra_ignore rejected; walking without it"); } diff --git a/crates/fff-core/src/watcher/background_watcher.rs b/crates/fff-core/src/watcher/background_watcher.rs index f5de0236e..7813846ca 100644 --- a/crates/fff-core/src/watcher/background_watcher.rs +++ b/crates/fff-core/src/watcher/background_watcher.rs @@ -805,6 +805,8 @@ fn index_new_directory( dir, repo.is_some(), follow_symlinks, + // Lines are base-relative; `filter` below applies them via the walker rules. + &[], 1, &Arc::new(std::sync::atomic::AtomicUsize::new(0)), ) { diff --git a/crates/fff-core/tests/extra_ignore_integration.rs b/crates/fff-core/tests/extra_ignore_integration.rs new file mode 100644 index 000000000..ca2ab6815 --- /dev/null +++ b/crates/fff-core/tests/extra_ignore_integration.rs @@ -0,0 +1,178 @@ +#![cfg(feature = "zlob")] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use fff_search::file_picker::FilePicker; +use fff_search::{FilePickerOptions, SharedFilePicker, SharedFrecency}; +use git2::Status; +use tempfile::TempDir; + +#[test] +fn collect_files_applies_extra_ignore_and_marks_included_files_ignored() { + let (_tmp, base) = init_repo("secrets/\n*.log\n"); + for rel in [ + "main.rs", + "debug.log", + "keep.log", + "secrets/key.env", + "secrets/deep/token.env", + "generated/out.rs", + ] { + write(&base, rel); + } + + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + extra_ignore: vec!["!secrets/".into(), "!keep.log".into(), "generated/".into()], + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + + for rel in ["keep.log", "secrets/key.env", "secrets/deep/token.env"] { + assert_eq!( + status_of(&picker, rel), + Some(Some(Status::IGNORED)), + "{rel}" + ); + } + assert!(status_of(&picker, "main.rs").is_some()); + assert_ne!(status_of(&picker, "main.rs"), Some(Some(Status::IGNORED))); + assert_eq!( + status_of(&picker, "debug.log"), + None, + "non-included ignores stay out" + ); + assert_eq!( + status_of(&picker, "generated/out.rs"), + None, + "extra ignores apply" + ); +} + +// Dotfiles repos ignore everything with `*`; plain git semantics need both lines. +#[test] +fn extra_ignore_rescues_star_ignored_subtree() { + let (_tmp, base) = init_repo("*\n"); + for rel in ["nvim/init.lua", "nvim/lua/mappings.lua", "zsh/zshrc"] { + write(&base, rel); + } + + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + extra_ignore: vec!["!nvim/".into(), "!nvim/**".into()], + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + + let mut rels: Vec = picker + .get_files() + .iter() + .map(|f| f.relative_path(&picker)) + .collect(); + rels.sort(); + assert_eq!(rels, vec!["nvim/init.lua", "nvim/lua/mappings.lua"]); +} + +// zlob rejects the whole list on an interior NUL; only the bad line may be dropped. +#[test] +fn nul_line_does_not_drop_other_rules() { + let (_tmp, base) = init_repo("secrets/\n"); + write(&base, "secrets/key.env"); + + let mut picker = FilePicker::new(FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: false, + extra_ignore: vec!["bad\0line".into(), "!secrets/".into()], + ..Default::default() + }) + .unwrap(); + picker.collect_files().unwrap(); + + assert!(status_of(&picker, "secrets/key.env").is_some()); +} + +// Runtime path: the git-status worker marks included files, and the watcher +// filters new files with the same rules the walk used. +#[test] +fn watcher_and_git_worker_honor_extra_ignore() { + let (_tmp, base) = init_repo("secrets/\n*.log\n"); + write(&base, "main.rs"); + write(&base, "secrets/key.env"); + fs::create_dir_all(base.join("generated")).unwrap(); + + let shared = SharedFilePicker::default(); + FilePicker::new_with_shared_state( + shared.clone(), + SharedFrecency::noop(), + FilePickerOptions { + base_path: base.to_string_lossy().into_owned(), + watch: true, + extra_ignore: vec!["!secrets/".into(), "generated/".into()], + ..Default::default() + }, + ) + .unwrap(); + assert!(shared.wait_for_scan(Duration::from_secs(30))); + assert!(shared.wait_for_watcher(Duration::from_secs(30))); + + assert!( + wait_until(|| shared_status(&shared, "secrets/key.env") == Some(Some(Status::IGNORED))), + "included file never got the ignored status" + ); + + write(&base, "debug.log"); + write(&base, "generated/new.rs"); + write(&base, "secrets/new.env"); + assert!( + wait_until(|| shared_status(&shared, "secrets/new.env").is_some()), + "watcher dropped a force-included file" + ); + assert_eq!(shared_status(&shared, "debug.log"), None); + assert_eq!(shared_status(&shared, "generated/new.rs"), None); +} + +fn init_repo(gitignore: &str) -> (TempDir, PathBuf) { + let tmp = TempDir::new().unwrap(); + // Git statuses only map onto the index under a symlink-free base (macOS /var -> /private/var). + let base = fs::canonicalize(tmp.path()).unwrap(); + git2::Repository::init(&base).unwrap(); + fs::write(base.join(".gitignore"), gitignore).unwrap(); + (tmp, base) +} + +fn write(base: &Path, rel: &str) { + let path = base.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"x").unwrap(); +} + +// `None` when not indexed, otherwise the file's git status. +fn status_of(picker: &FilePicker, rel: &str) -> Option> { + picker + .get_files() + .iter() + .find(|f| f.relative_path(picker) == rel) + .map(|f| f.git_status) +} + +fn shared_status(shared: &SharedFilePicker, rel: &str) -> Option> { + let guard = shared.read().ok()?; + status_of(guard.as_ref()?, rel) +} + +fn wait_until(mut cond: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if cond() { + return true; + } + std::thread::sleep(Duration::from_millis(50)); + } + false +} diff --git a/crates/fff-mcp/src/main.rs b/crates/fff-mcp/src/main.rs index 2ae2fb0c0..d27a5e220 100644 --- a/crates/fff-mcp/src/main.rs +++ b/crates/fff-mcp/src/main.rs @@ -178,6 +178,11 @@ pub(crate) struct Args { #[arg(long = "follow-symlinks")] follow_symlinks: bool, + /// Extra `.gitignore`-syntax line relative to the base path, taking precedence + /// over every ignore file. Repeatable; `!pattern` force-includes ignored paths. + #[arg(long = "extra-ignore", value_name = "LINE")] + extra_ignore: Vec, + /// Allow indexing the user's home directory. FFF refuses to init in `~` /// unless this is set. Also settable via FFF_ENABLE_HOME_SCAN=1. #[arg( @@ -366,6 +371,7 @@ async fn main() -> Result<(), Box> { enable_home_dir_scanning: args.enable_home_scan, enable_fs_root_scanning: args.enable_root_scan, git_recency: Default::default(), + extra_ignore: args.extra_ignore.clone(), }, ) .map_err(|e| format!("Failed to init file picker: {}", e))?; diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index b5cf3a32d..b774b82b3 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -82,6 +82,7 @@ struct PickerInitOpts { enable_home_dir_scanning: bool, enable_filename_constraint: bool, git_recency: Option, + extra_ignore: Option>, } impl PickerInitOpts { @@ -109,6 +110,7 @@ impl PickerInitOpts { git_recency: Self::git_recency_from_lua( t.get::>("git_recency")?, )?, + extra_ignore: t.get::>>("extra_ignore")?, }), other => Err(LuaError::RuntimeError(format!( "init opts must be a table, boolean, or nil — got {}", @@ -185,6 +187,7 @@ pub fn init_file_picker( enable_fs_root_scanning: opts.enable_fs_root_scanning, enable_home_dir_scanning: opts.enable_home_dir_scanning, git_recency: opts.git_recency.unwrap_or_default(), + extra_ignore: opts.extra_ignore.unwrap_or_default(), ..Default::default() }, ) @@ -232,7 +235,7 @@ pub fn restart_index_in_path( // Inherit current picker's scanning flags when caller didn't pass // explicit opts — otherwise a `:cd ~` after init would silently lose // the user's `enable_home_dir_scanning = true` setting. - let (follow_symlinks, fs_root, home_dir, git_recency) = { + let (follow_symlinks, fs_root, home_dir, git_recency, extra_ignore) = { let guard = match FILE_PICKER.read() { Ok(g) => g, Err(_) => return, @@ -251,12 +254,16 @@ pub fn restart_index_in_path( p.fs_root_scanning_enabled() || opts.enable_fs_root_scanning, p.home_dir_scanning_enabled() || opts.enable_home_dir_scanning, opts.git_recency.unwrap_or_else(|| p.git_recency_config()), + opts.extra_ignore + .clone() + .unwrap_or_else(|| p.extra_ignore().to_vec()), ), None => ( opts.follow_symlinks, opts.enable_fs_root_scanning, opts.enable_home_dir_scanning, opts.git_recency.unwrap_or_default(), + opts.extra_ignore.clone().unwrap_or_default(), ), } }; @@ -280,6 +287,7 @@ pub fn restart_index_in_path( enable_fs_root_scanning: fs_root, enable_home_dir_scanning: home_dir, git_recency, + extra_ignore, ..Default::default() }, ) { diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 837a90467..9652668af 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -184,6 +184,7 @@ impl FileFinder { enable_fs_root_scanning=false, enable_home_dir_scanning=false, follow_symlinks=false, + extra_ignore=None, ))] #[allow(clippy::too_many_arguments)] fn new( @@ -203,6 +204,7 @@ impl FileFinder { enable_fs_root_scanning: bool, enable_home_dir_scanning: bool, follow_symlinks: bool, + extra_ignore: Option>, ) -> PyResult { let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); @@ -256,6 +258,7 @@ impl FileFinder { enable_fs_root_scanning, enable_home_dir_scanning, git_recency: Default::default(), + extra_ignore: extra_ignore.unwrap_or_default(), }, ) .map_err(py_err) @@ -832,6 +835,7 @@ impl FileFinder { home_dir, follow_symlinks, git_recency, + extra_ignore, ) = { let guard = picker.read().map_err(py_err)?; if let Some(ref picker) = *guard { @@ -844,6 +848,7 @@ impl FileFinder { picker.home_dir_scanning_enabled(), picker.follows_symlinks(), picker.git_recency_config(), + picker.extra_ignore().to_vec(), ) } else { ( @@ -856,6 +861,7 @@ impl FileFinder { false, false, Default::default(), + Vec::new(), ) } }; @@ -878,6 +884,7 @@ impl FileFinder { enable_fs_root_scanning: fs_root, enable_home_dir_scanning: home_dir, git_recency, + extra_ignore, }, ) .map_err(py_err) diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index 709c0ddb4..d8ab85fef 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -88,6 +88,7 @@ local M = {} --- @field lazy_sync boolean --- @field prompt_vim_mode boolean --- @field follow_symlinks boolean +--- @field extra_ignore string[] `.gitignore`-syntax lines overriding every ignore file; `!pattern` force-includes --- @field enable_fs_root_scanning boolean --- @field enable_home_dir_scanning boolean --- @field layout FffLayoutConfig @@ -257,6 +258,9 @@ local function init() prompt_vim_mode = false, -- set to true to enable vim-mode in the prompt: leaves insert for normal mode bindings (also allows p or l to jump around) the second closes the picker wrap_around = false, -- set to true to wrap cursor to the opposite end when reaching the first/last item follow_symlinks = false, -- set to true to follow symbolic links during file indexing + -- Extra .gitignore-syntax lines relative to the indexed root, taking precedence over every + -- ignore file. `!pattern` force-includes ignored paths, e.g. { '!.env*', '!logs/' } + extra_ignore = {}, -- Allow fff in the user's $HOME director. enable_home_dir_scanning = true, -- Allow fff in a filesystem root (e.g. `/`, `C:\`) diff --git a/lua/fff/core.lua b/lua/fff/core.lua index 79ff7565a..0526e08de 100644 --- a/lua/fff/core.lua +++ b/lua/fff/core.lua @@ -126,6 +126,7 @@ M.change_indexing_directory = function(new_path) enable_home_dir_scanning = config.enable_home_dir_scanning, enable_filename_constraint = config.grep and config.grep.enable_filename_constraint, git_recency = config.git and config.git.recency, + extra_ignore = config.extra_ignore, }) if not ok then vim.notify('Failed to change directory: ' .. err, vim.log.levels.ERROR) @@ -193,6 +194,7 @@ M.ensure_initialized = function() enable_home_dir_scanning = config.enable_home_dir_scanning, enable_filename_constraint = config.grep and config.grep.enable_filename_constraint, git_recency = config.git and config.git.recency, + extra_ignore = config.extra_ignore, }) if not ok then vim.notify('Failed to initialize file picker: ' .. tostring(result), vim.log.levels.ERROR) diff --git a/packages/fff-bun/src/fff-api.ts b/packages/fff-bun/src/fff-api.ts index 50c42c26a..09cf98df3 100644 --- a/packages/fff-bun/src/fff-api.ts +++ b/packages/fff-bun/src/fff-api.ts @@ -100,6 +100,12 @@ export interface InitOptions { enableHomeDirScanning?: boolean; /** Follow symlinks for directories */ followSymlinks?: boolean; + /** + * Extra `.gitignore`-syntax lines relative to `basePath`, taking precedence + * over every ignore file. `!pattern` force-includes ignored paths, e.g. + * `["!.env*", "!logs/"]`; plain patterns exclude more. + */ + extraIgnore?: string[]; } /** diff --git a/packages/fff-bun/src/ffi.ts b/packages/fff-bun/src/ffi.ts index f37b01105..3a6008380 100644 --- a/packages/fff-bun/src/ffi.ts +++ b/packages/fff-bun/src/ffi.ts @@ -417,8 +417,8 @@ const RES_HANDLE = 16; // *mut c_void (8) const RES_INT_VALUE = 24; // i64 (8) // MUST match `crates/fff-c/src/ffi_types.rs::FffCreateOptions` -const FFF_CREATE_OPTIONS_VERSION = 1; -const FFF_CREATE_OPTIONS_SIZE = 88; +const FFF_CREATE_OPTIONS_VERSION = 3; +const FFF_CREATE_OPTIONS_SIZE = 96; const FCO_VERSION = 0; const FCO_BASE_PATH = 8; const FCO_FRECENCY_DB_PATH = 16; @@ -434,6 +434,8 @@ const FCO_CACHE_BUDGET_MAX_BYTES = 64; const FCO_CACHE_BUDGET_MAX_FILE_SIZE = 72; const FCO_ENABLE_FS_ROOT_SCANNING = 80; const FCO_ENABLE_HOME_DIR_SCANNING = 81; +const FCO_FOLLOW_SYMLINKS = 82; +const FCO_EXTRA_IGNORE = 88; function readResultEnvelope( resultPtr: Pointer | null, @@ -520,7 +522,7 @@ export type NativeHandle = Pointer; /** * Create a new file finder instance. * - * Hand-encodes a [`FffCreateOptions`] struct (88 bytes, locked offsets — see + * Hand-encodes a [`FffCreateOptions`] struct (96 bytes, locked offsets — see * `crates/fff-c/src/ffi_types.rs::options_layout_tests`) into a Buffer and * passes its pointer to `fff_create_instance_with`. Inner cstring addresses * come from Bun's native `ptr(buffer)` primitive — no round-trip helpers, @@ -547,6 +549,8 @@ export function ffiCreate( cacheBudgetMaxFileSize: bigint, enableFsRootScanning: boolean, enableHomeDirScanning: boolean, + followSymlinks: boolean, + extraIgnore: string[], ): Result { const library = loadLibrary(); @@ -557,6 +561,7 @@ export function ffiCreate( const historyCStr = encodeCStringBuf(historyDbPath); const logFileCStr = encodeCStringBuf(logFilePath); const logLevelCStr = encodeCStringBuf(logLevel); + const extraIgnoreCStr = encodeCStringBuf(extraIgnore.join("\n")); const opts = Buffer.alloc(FFF_CREATE_OPTIONS_SIZE); opts.writeUInt32LE(FFF_CREATE_OPTIONS_VERSION, FCO_VERSION); @@ -574,6 +579,8 @@ export function ffiCreate( opts.writeBigUInt64LE(cacheBudgetMaxFileSize, FCO_CACHE_BUDGET_MAX_FILE_SIZE); opts.writeUInt8(enableFsRootScanning ? 1 : 0, FCO_ENABLE_FS_ROOT_SCANNING); opts.writeUInt8(enableHomeDirScanning ? 1 : 0, FCO_ENABLE_HOME_DIR_SCANNING); + opts.writeUInt8(followSymlinks ? 1 : 0, FCO_FOLLOW_SYMLINKS); + writePtrLE(opts, FCO_EXTRA_IGNORE, extraIgnoreCStr); const resultPtr = library.symbols.fff_create_instance_with(ptr(opts)); diff --git a/packages/fff-bun/src/finder.ts b/packages/fff-bun/src/finder.ts index 6f5b23aea..1b00c3ee7 100644 --- a/packages/fff-bun/src/finder.ts +++ b/packages/fff-bun/src/finder.ts @@ -143,6 +143,8 @@ export class FileFinder implements FileFinderApi { BigInt(options.cacheBudgetMaxFileSize ?? 0), options.enableFsRootScanning ?? false, options.enableHomeDirScanning ?? false, + options.followSymlinks ?? false, + options.extraIgnore ?? [], ); if (!result.ok) { diff --git a/packages/fff-node/src/fff-api.ts b/packages/fff-node/src/fff-api.ts index 50c42c26a..09cf98df3 100644 --- a/packages/fff-node/src/fff-api.ts +++ b/packages/fff-node/src/fff-api.ts @@ -100,6 +100,12 @@ export interface InitOptions { enableHomeDirScanning?: boolean; /** Follow symlinks for directories */ followSymlinks?: boolean; + /** + * Extra `.gitignore`-syntax lines relative to `basePath`, taking precedence + * over every ignore file. `!pattern` force-includes ignored paths, e.g. + * `["!.env*", "!logs/"]`; plain patterns exclude more. + */ + extraIgnore?: string[]; } /** diff --git a/packages/fff-node/src/ffi.ts b/packages/fff-node/src/ffi.ts index 5bf060d3c..3d465010a 100644 --- a/packages/fff-node/src/ffi.ts +++ b/packages/fff-node/src/ffi.ts @@ -87,10 +87,11 @@ const FFF_CREATE_OPTIONS_STRUCT = { enable_fs_root_scanning: DataType.U8, enable_home_dir_scanning: DataType.U8, follow_symlinks: DataType.U8, + extra_ignore: DataType.String, }; // ALWAYS KEEP IN SYNC WITH fff.h -const FFF_CREATE_OPTIONS_VERSION = 2; +const FFF_CREATE_OPTIONS_VERSION = 3; /** Grep mode constants matching the C API (u8). */ const GREP_MODE_PLAIN = 0; @@ -369,6 +370,7 @@ export function ffiCreate( enableFsRootScanning: boolean, enableHomeDirScanning: boolean, followSymlinks: boolean, + extraIgnore: string[], ): Result { loadLibrary(); @@ -389,6 +391,7 @@ export function ffiCreate( enable_fs_root_scanning: enableFsRootScanning ? 1 : 0, enable_home_dir_scanning: enableHomeDirScanning ? 1 : 0, follow_symlinks: followSymlinks ? 1 : 0, + extra_ignore: extraIgnore.join("\n"), }; const rawPtr = load({ diff --git a/packages/fff-node/src/finder.ts b/packages/fff-node/src/finder.ts index d49baf363..475781b97 100644 --- a/packages/fff-node/src/finder.ts +++ b/packages/fff-node/src/finder.ts @@ -136,6 +136,7 @@ export class FileFinder implements FileFinderApi { options.enableFsRootScanning ?? false, options.enableHomeDirScanning ?? false, options.followSymlinks ?? false, + options.extraIgnore ?? [], ); if (!result.ok) { diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi index c3025619e..5320b6ba6 100644 --- a/packages/fff-python/src/fff/__init__.pyi +++ b/packages/fff-python/src/fff/__init__.pyi @@ -180,6 +180,7 @@ class FileFinder: enable_fs_root_scanning: bool = False, enable_home_dir_scanning: bool = False, follow_symlinks: bool = False, + extra_ignore: Sequence[str] | None = None, ) -> None: ... def __enter__(self) -> FileFinder: ... def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... diff --git a/packages/shared/fff-api.ts b/packages/shared/fff-api.ts index cc53a3aa8..c1316c4b0 100644 --- a/packages/shared/fff-api.ts +++ b/packages/shared/fff-api.ts @@ -94,6 +94,12 @@ export interface InitOptions { enableHomeDirScanning?: boolean; /** Follow symlinks for directories */ followSymlinks?: boolean; + /** + * Extra `.gitignore`-syntax lines relative to `basePath`, taking precedence + * over every ignore file. `!pattern` force-includes ignored paths, e.g. + * `["!.env*", "!logs/"]`; plain patterns exclude more. + */ + extraIgnore?: string[]; } /**