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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,27 @@ require('fff').setup({
})
```

#### Git Recency Scoring

FFF.nvim can analyze recent commits on your current branch and give a small scoring bonus to files that were recently changed. This helps surface contextually relevant files — especially useful after switching branches or pulling changes.

- Commits with too many file changes (merge commits, bulk refactors) are automatically ignored
- The bonus is additive and independent from frecency scoring
- Scores are refreshed automatically on branch switches and new commits

```lua
require('fff').setup({
git = {
recency = {
enabled = true, -- Enable git recency scoring (default: true)
max_commits = 10, -- Number of recent commits to analyze (default: 10)
max_files_per_commit = 50, -- Skip commits touching more files than this (default: 50)
max_bonus = 15, -- Max score bonus for the most recent commit (default: 15)
},
},
})
```

#### File Filtering

FFF.nvim respects `.gitignore` patterns automatically. To filter files from the picker without modifying `.gitignore`, create a `.ignore` file in your project root:
Expand Down
2 changes: 2 additions & 0 deletions crates/fff-c/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ pub unsafe extern "C" fn fff_create(opts_json: *const c_char) -> *mut FffResult
opts.warmup_mmap_cache,
Arc::clone(&shared_picker),
Arc::clone(&shared_frecency),
Default::default(),
) {
return FffResult::err(&format!("Failed to init file picker: {}", e));
}
Expand Down Expand Up @@ -516,6 +517,7 @@ pub unsafe extern "C" fn fff_restart_index(
warmup,
Arc::clone(&inst.picker),
Arc::clone(&inst.frecency),
Default::default(),
) {
Ok(()) => FffResult::ok_empty(),
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
Expand Down
7 changes: 7 additions & 0 deletions crates/fff-core/src/background_watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,13 @@ fn handle_debounced_events(
if let Err(e) = result {
error!("Failed to refresh git status: {:?}", e);
}

// Also refresh git recency scores since HEAD/refs changed
// (new commits, branch switches, etc.)
if let Err(e) = FilePicker::refresh_git_recency(shared_picker) {
error!("Failed to refresh git recency: {:?}", e);
}

return;
}

Expand Down
79 changes: 78 additions & 1 deletion crates/fff-core/src/file_picker.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
use crate::background_watcher::BackgroundWatcher;
use crate::error::Error;
use crate::frecency::FrecencyTracker;
use crate::git::GitStatusCache;
use crate::git::{GitRecencyConfig, GitStatusCache};
use crate::query_tracker::QueryMatchEntry;
use crate::score::match_and_score_files;
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
use crate::{SharedFrecency, SharedPicker};
use fff_query_parser::FFFQuery;
use git2::{Repository, Status, StatusOptions};
use rayon::prelude::*;
use std::collections::HashMap;
use std::fmt::Debug;
use std::io::Read;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -186,6 +187,7 @@ pub struct FilePicker {
scanned_files_count: Arc<AtomicUsize>,
background_watcher: Option<BackgroundWatcher>,
warmup_mmap_cache: bool,
git_recency_config: GitRecencyConfig,
}

impl std::fmt::Debug for FilePicker {
Expand All @@ -211,6 +213,10 @@ impl FilePicker {
self.warmup_mmap_cache
}

pub fn git_recency_config(&self) -> GitRecencyConfig {
self.git_recency_config
}

pub fn git_root(&self) -> Option<&Path> {
self.sync_data.git_workdir.as_deref()
}
Expand All @@ -234,6 +240,7 @@ impl FilePicker {
warmup_mmap_cache: bool,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
git_recency_config: GitRecencyConfig,
) -> Result<(), Error> {
info!(
"Initializing FilePicker with base_path: {}, warmup: {}",
Expand All @@ -258,6 +265,7 @@ impl FilePicker {
scanned_files_count: Arc::clone(&synced_files_count),
background_watcher: None,
warmup_mmap_cache,
git_recency_config,
};

// Place the picker into the shared handle before spawning the
Expand All @@ -274,6 +282,7 @@ impl FilePicker {
warmup_mmap_cache,
shared_picker,
shared_frecency,
git_recency_config,
);

Ok(())
Expand Down Expand Up @@ -441,6 +450,55 @@ impl FilePicker {
Ok(statuses_count)
}

/// Update git recency scores for all files using a precomputed recency map.
pub fn update_git_recency_scores(&mut self, recency_scores: &HashMap<PathBuf, i32>) {
for file in &mut self.sync_data.files {
file.git_recency_score = recency_scores.get(&file.path).copied().unwrap_or(0);
}
}

/// Refreshes git recency scores using the provided shared picker handle.
/// Reads recent commits from the git repository and updates each file's
/// recency score based on how recently it appeared in commits.
pub fn refresh_git_recency(shared_picker: &SharedPicker) -> Result<(), Error> {
let (git_workdir, config) = {
let guard = shared_picker.read().map_err(|_| Error::AcquireItemLock)?;
let Some(ref picker) = *guard else {
return Err(Error::FilePickerMissing);
};
(
picker.git_root().map(Path::to_path_buf),
picker.git_recency_config,
)
};

let Some(git_workdir) = git_workdir else {
return Ok(());
};

let repo = match Repository::open(&git_workdir) {
Ok(r) => r,
Err(e) => {
tracing::debug!(?e, "Failed to open repo for git recency refresh");
return Ok(());
}
};

let recency_scores = crate::git::get_recent_commit_files(&repo, &config);

if !recency_scores.is_empty() {
let mut guard = shared_picker.write().map_err(|_| Error::AcquireItemLock)?;
let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
picker.update_git_recency_scores(&recency_scores);
debug!(
files_scored = recency_scores.len(),
"Git recency scores refreshed"
);
}

Ok(())
}

pub fn update_single_file_frecency(
&mut self,
file_path: impl AsRef<Path>,
Expand Down Expand Up @@ -644,6 +702,7 @@ fn spawn_scan_and_watcher(
warmup_mmap_cache: bool,
shared_picker: SharedPicker,
shared_frecency: SharedFrecency,
git_recency_config: GitRecencyConfig,
) {
std::thread::spawn(move || {
// scan_signal is already `true` (set by the caller before spawning)
Expand Down Expand Up @@ -671,6 +730,24 @@ fn spawn_scan_and_watcher(
error!("Failed to write scan results into picker");
}

// Compute git recency scores after initial scan
if let Some(ref workdir) = git_workdir
&& let Ok(repo) = Repository::open(workdir)
{
let recency_scores =
crate::git::get_recent_commit_files(&repo, &git_recency_config);
if !recency_scores.is_empty()
&& let Ok(mut guard) = shared_picker.write()
&& let Some(ref mut picker) = *guard
{
picker.update_git_recency_scores(&recency_scores);
info!(
files_scored = recency_scores.len(),
"Initial git recency scores applied"
);
}
}

// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
// The aggressive parallel warmup was causing cache thrashing and delaying
// initial searches. Now it runs async and doesn't block.
Expand Down
Loading
Loading