Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
});
```

Expand Down
7 changes: 6 additions & 1 deletion crates/fff-c/include/fff.h
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down Expand Up @@ -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;

/**
Expand Down
13 changes: 10 additions & 3 deletions crates/fff-c/src/ffi_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down Expand Up @@ -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 {
Expand All @@ -80,6 +84,7 @@ impl FffCreateOptions {
enable_fs_root_scanning: false,
enable_home_dir_scanning: false,
follow_symlinks: false,
extra_ignore: ptr::null(),
}
}
}
Expand Down Expand Up @@ -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::<FffCreateOptions>(), 88);
// v1/v2 occupied 88 bytes; the v3 pointer is appended at offset 88.
assert_eq!(size_of::<FffCreateOptions>(), 96);
assert_eq!(align_of::<FffCreateOptions>(), 8);

assert_eq!(offset_of!(FffCreateOptions, version), 0);
Expand All @@ -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);
}
}
16 changes: 16 additions & 0 deletions crates/fff-c/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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));
Expand Down Expand Up @@ -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);

Expand All @@ -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(),
Expand Down
105 changes: 97 additions & 8 deletions crates/fff-core/src/file_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

impl Default for FilePickerOptions {
Expand All @@ -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(),
}
}
}
Expand All @@ -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,
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -991,6 +1013,7 @@ impl FilePicker {
signals,
scanned_files_counter,
trace_span,
extra_ignore,
ScanConfig {
warmup,
content_indexing,
Expand Down Expand Up @@ -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(
Expand All @@ -1030,6 +1056,7 @@ impl FilePicker {
&empty_frecency,
self.mode,
self.follow_symlinks,
&self.extra_ignore,
)?;

self.sync_data = sync;
Expand All @@ -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);
}
}
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<String> = 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<bool> = 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);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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<String, i16>>) {
Expand Down Expand Up @@ -2061,11 +2145,14 @@ impl FileSync {
git_workdir
}

pub(crate) fn spawn_git_status(git_workdir: PathBuf) -> JoinHandle<Option<GitStatusCache>> {
pub(crate) fn spawn_git_status(
git_workdir: PathBuf,
include_ignored: bool,
) -> JoinHandle<Option<GitStatusCache>> {
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),
)
})
}
Expand All @@ -2081,6 +2168,7 @@ impl FileSync {
shared_frecency: &SharedFrecency,
mode: FFFMode,
follow_symlinks: bool,
extra_ignore: &[String],
) -> Result<FileSync, Error> {
let scan_start = std::time::Instant::now();
info!("SCAN: Starting filesystem walk and git status (async)");
Expand All @@ -2097,6 +2185,7 @@ impl FileSync {
base_path,
is_git_repo,
follow_symlinks,
extra_ignore,
bg_threads,
synced_files_count,
)?;
Expand Down
Loading