diff --git a/README.md b/README.md index 56019df2..db61748e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/crates/fff-c/src/lib.rs b/crates/fff-c/src/lib.rs index 03f9ad57..cab32391 100644 --- a/crates/fff-c/src/lib.rs +++ b/crates/fff-c/src/lib.rs @@ -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)); } @@ -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)), diff --git a/crates/fff-core/src/background_watcher.rs b/crates/fff-core/src/background_watcher.rs index 884f91c9..dd55eb15 100644 --- a/crates/fff-core/src/background_watcher.rs +++ b/crates/fff-core/src/background_watcher.rs @@ -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; } diff --git a/crates/fff-core/src/file_picker.rs b/crates/fff-core/src/file_picker.rs index 587df2d5..5a59e8a9 100644 --- a/crates/fff-core/src/file_picker.rs +++ b/crates/fff-core/src/file_picker.rs @@ -1,7 +1,7 @@ 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}; @@ -9,6 +9,7 @@ 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}; @@ -186,6 +187,7 @@ pub struct FilePicker { scanned_files_count: Arc, background_watcher: Option, warmup_mmap_cache: bool, + git_recency_config: GitRecencyConfig, } impl std::fmt::Debug for FilePicker { @@ -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() } @@ -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: {}", @@ -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 @@ -274,6 +282,7 @@ impl FilePicker { warmup_mmap_cache, shared_picker, shared_frecency, + git_recency_config, ); Ok(()) @@ -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) { + 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, @@ -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) @@ -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. diff --git a/crates/fff-core/src/git.rs b/crates/fff-core/src/git.rs index 4b0c308e..f5013299 100644 --- a/crates/fff-core/src/git.rs +++ b/crates/fff-core/src/git.rs @@ -1,6 +1,7 @@ use crate::error::Result; use git2::{Repository, Status, StatusOptions}; use std::{ + collections::HashMap, fmt::Debug, path::{Path, PathBuf}, }; @@ -153,3 +154,200 @@ pub fn format_git_status(status: Option) -> &'static str { } } } + +/// Configuration for git recency scoring. +#[derive(Debug, Clone, Copy)] +pub struct GitRecencyConfig { + /// Maximum number of recent commits to analyze (default: 10) + pub max_commits: usize, + /// Ignore commits that touch more files than this threshold (default: 50) + pub max_files_per_commit: usize, + /// Maximum bonus score for a file in the most recent commit (default: 15) + pub max_bonus: i32, +} + +impl Default for GitRecencyConfig { + fn default() -> Self { + Self { + max_commits: 10, + max_files_per_commit: 50, + max_bonus: 15, + } + } +} + +/// Analyze the last N commits on the current branch and compute a recency score +/// for each file that was touched. Uses max-only semantics: each file gets the +/// score from its most recent qualifying commit appearance. +/// +/// Score formula: `max_bonus * (max_commits - commit_position) / max_commits` +/// where commit_position 0 = most recent. +/// +/// Commits with more files changed than `config.max_files_per_commit` are skipped +/// (filters out merge commits, bulk refactors, initial imports, etc.). +/// +/// Returns a map from absolute file paths to their recency scores. +#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)] +pub fn get_recent_commit_files( + repo: &Repository, + config: &GitRecencyConfig, +) -> HashMap { + let mut scores: HashMap = HashMap::new(); + + if config.max_commits == 0 || config.max_bonus <= 0 { + return scores; + } + + let workdir = match repo.workdir() { + Some(w) => w, + None => return scores, // bare repo + }; + + let head = match repo.head() { + Ok(h) => h, + Err(e) => { + tracing::debug!(?e, "Failed to get HEAD for git recency"); + return scores; + } + }; + + let head_oid = match head.target() { + Some(oid) => oid, + None => return scores, + }; + + let mut revwalk = match repo.revwalk() { + Ok(r) => r, + Err(e) => { + tracing::debug!(?e, "Failed to create revwalk for git recency"); + return scores; + } + }; + + if revwalk.push(head_oid).is_err() { + return scores; + } + + // Walk commits, assigning position 0 to the most recent qualifying commit + let mut qualifying_position: usize = 0; + + for oid_result in revwalk { + if qualifying_position >= config.max_commits { + break; + } + + let oid = match oid_result { + Ok(oid) => oid, + Err(_) => continue, + }; + + let commit = match repo.find_commit(oid) { + Ok(c) => c, + Err(_) => continue, + }; + + let commit_tree = match commit.tree() { + Ok(t) => t, + Err(_) => continue, + }; + + // Diff against first parent (standard for merge commits — shows what + // the branch actually changed, not what was merged in) + let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok()); + + let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) { + Ok(d) => d, + Err(_) => continue, + }; + + // Collect files from this commit's diff + let mut commit_files: Vec = Vec::new(); + let deltas = diff.deltas(); + let delta_count = deltas.len(); + + // Skip commits with too many changes + if delta_count > config.max_files_per_commit { + tracing::trace!( + delta_count, + max = config.max_files_per_commit, + "Skipping large commit for git recency" + ); + continue; + } + + for delta in deltas { + // Prefer the new_file path (handles renames correctly) + if let Some(path) = delta.new_file().path() { + commit_files.push(workdir.join(path)); + } + } + + // Linear decay: most recent qualifying commit gets max_bonus, + // oldest gets max_bonus / max_commits + let score = config.max_bonus * (config.max_commits - qualifying_position) as i32 + / config.max_commits as i32; + + // Max-only: only insert if the file doesn't already have a higher score + // (from a more recent commit) + for file_path in commit_files { + scores.entry(file_path).or_insert(score); + } + + qualifying_position += 1; + } + + tracing::debug!( + files_scored = scores.len(), + commits_analyzed = qualifying_position, + "Git recency analysis complete" + ); + + scores +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_git_recency_config_default() { + let config = GitRecencyConfig::default(); + assert_eq!(config.max_commits, 10); + assert_eq!(config.max_files_per_commit, 50); + assert_eq!(config.max_bonus, 15); + } + + #[test] + fn test_git_recency_disabled_with_zero_commits() { + let config = GitRecencyConfig { + max_commits: 0, + ..Default::default() + }; + // Can't easily test with a real repo, but verify the early return path + // by checking config values + assert_eq!(config.max_commits, 0); + } + + #[test] + fn test_git_recency_score_formula() { + // Verify the linear decay formula produces expected values + let max_bonus: i32 = 15; + let max_commits: usize = 10; + + // Position 0 (most recent) -> 15 * 10/10 = 15 + let score_0 = max_bonus * (max_commits - 0) as i32 / max_commits as i32; + assert_eq!(score_0, 15); + + // Position 1 -> 15 * 9/10 = 13 + let score_1 = max_bonus * (max_commits - 1) as i32 / max_commits as i32; + assert_eq!(score_1, 13); + + // Position 5 -> 15 * 5/10 = 7 + let score_5 = max_bonus * (max_commits - 5) as i32 / max_commits as i32; + assert_eq!(score_5, 7); + + // Position 9 (oldest) -> 15 * 1/10 = 1 + let score_9 = max_bonus * (max_commits - 9) as i32 / max_commits as i32; + assert_eq!(score_9, 1); + } +} diff --git a/crates/fff-core/src/lib.rs b/crates/fff-core/src/lib.rs index 10bf148a..6285ab0c 100644 --- a/crates/fff-core/src/lib.rs +++ b/crates/fff-core/src/lib.rs @@ -38,5 +38,6 @@ pub use fff_query_parser::{ Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location, }; pub use file_picker::{FuzzySearchOptions, ScanProgress}; +pub use git::GitRecencyConfig; pub use grep::{GrepMatch, GrepMode, GrepResult, GrepSearchOptions}; pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult}; diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 23e561d9..bab8f91e 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -294,12 +294,15 @@ pub fn match_and_score_files<'a>( } }; + let git_recency_boost = file.git_recency_score; + let total = base_score .saturating_add(frecency_boost) .saturating_add(distance_penalty) .saturating_add(filename_bonus) .saturating_add(current_file_penalty) - .saturating_add(combo_match_boost); + .saturating_add(combo_match_boost) + .saturating_add(git_recency_boost); let score = Score { total, @@ -312,6 +315,7 @@ pub fn match_and_score_files<'a>( 0 }, frecency_boost, + git_recency_boost, distance_penalty, combo_match_boost, exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact), @@ -363,9 +367,12 @@ pub(crate) fn score_filtered_by_frecency<'a>( let total_frecency_score = file.access_frecency_score as i32 + (file.modification_frecency_score as i32).saturating_mul(4); + let git_recency_boost = file.git_recency_score; let current_file_penalty = calculate_current_file_penalty(file, total_frecency_score, context); - let total = total_frecency_score.saturating_add(current_file_penalty); + let total = total_frecency_score + .saturating_add(git_recency_boost) + .saturating_add(current_file_penalty); let score = Score { total, @@ -376,6 +383,7 @@ pub(crate) fn score_filtered_by_frecency<'a>( combo_match_boost: 0, current_file_penalty, frecency_boost: total_frecency_score, + git_recency_boost, exact_match: false, match_type: "frecency", }; @@ -503,6 +511,7 @@ mod tests { special_filename_bonus: 0, current_file_penalty: 0, frecency_boost: 0, + git_recency_boost: 0, exact_match: false, match_type: "test", combo_match_boost: 0, diff --git a/crates/fff-core/src/types.rs b/crates/fff-core/src/types.rs index d2be243d..71251d77 100644 --- a/crates/fff-core/src/types.rs +++ b/crates/fff-core/src/types.rs @@ -29,6 +29,7 @@ pub struct FileItem { pub modification_frecency_score: i64, pub total_frecency_score: i64, pub git_status: Option, + pub git_recency_score: i32, pub is_binary: bool, /// Lazily-initialized memory-mapped file contents for grep. /// Initialized on first grep access via `OnceLock`; lock-free on subsequent reads. @@ -50,6 +51,7 @@ impl Clone for FileItem { modification_frecency_score: self.modification_frecency_score, total_frecency_score: self.total_frecency_score, git_status: self.git_status, + git_recency_score: self.git_recency_score, is_binary: self.is_binary, // Don't clone the mmap — the clone lazily re-creates it on demand mmap: OnceLock::new(), @@ -82,6 +84,7 @@ impl FileItem { modification_frecency_score: 0, total_frecency_score: 0, git_status, + git_recency_score: 0, is_binary, mmap: OnceLock::new(), } @@ -155,6 +158,7 @@ pub struct Score { pub filename_bonus: i32, pub special_filename_bonus: i32, pub frecency_boost: i32, + pub git_recency_boost: i32, pub distance_penalty: i32, pub current_file_penalty: i32, pub combo_match_boost: i32, diff --git a/crates/fff-nvim/benches/indexing_and_search.rs b/crates/fff-nvim/benches/indexing_and_search.rs index db554317..799a9e0c 100644 --- a/crates/fff-nvim/benches/indexing_and_search.rs +++ b/crates/fff-nvim/benches/indexing_and_search.rs @@ -31,6 +31,7 @@ fn init_file_picker_internal( false, Arc::clone(shared_picker), Arc::clone(shared_frecency), + Default::default(), ) .map_err(|e| format!("Failed to create FilePicker: {:?}", e)) } diff --git a/crates/fff-nvim/src/bin/jemalloc_profile.rs b/crates/fff-nvim/src/bin/jemalloc_profile.rs index 39c89f4a..b8ebfd9c 100644 --- a/crates/fff-nvim/src/bin/jemalloc_profile.rs +++ b/crates/fff-nvim/src/bin/jemalloc_profile.rs @@ -190,6 +190,7 @@ fn main() -> Result<(), Box> { false, Arc::clone(&shared_picker), Arc::clone(&shared_frecency), + Default::default(), )?; // Wait for initial scan diff --git a/crates/fff-nvim/src/bin/search_profiler.rs b/crates/fff-nvim/src/bin/search_profiler.rs index fcc4d20c..58d705b4 100644 --- a/crates/fff-nvim/src/bin/search_profiler.rs +++ b/crates/fff-nvim/src/bin/search_profiler.rs @@ -83,6 +83,7 @@ fn main() { false, Arc::clone(&shared_picker), Arc::clone(&shared_frecency), + Default::default(), ) .expect("Failed to init FilePicker"); diff --git a/crates/fff-nvim/src/bin/test_memory_leak.rs b/crates/fff-nvim/src/bin/test_memory_leak.rs index a6f44785..2acb4417 100644 --- a/crates/fff-nvim/src/bin/test_memory_leak.rs +++ b/crates/fff-nvim/src/bin/test_memory_leak.rs @@ -89,6 +89,7 @@ fn main() -> Result<(), Box> { false, Arc::clone(&shared_picker), Arc::clone(&shared_frecency), + Default::default(), )?; // Wait for initial scan to complete diff --git a/crates/fff-nvim/src/bin/test_watcher.rs b/crates/fff-nvim/src/bin/test_watcher.rs index b20d05f5..897f8dda 100644 --- a/crates/fff-nvim/src/bin/test_watcher.rs +++ b/crates/fff-nvim/src/bin/test_watcher.rs @@ -50,6 +50,7 @@ fn main() -> Result<(), Box> { false, Arc::clone(&shared_picker), Arc::clone(&shared_frecency), + Default::default(), )?; // Get initial file count from shared state diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 783b1e4b..31706b5c 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -4,8 +4,8 @@ use fff_core::file_picker::FilePicker; use fff_core::frecency::FrecencyTracker; use fff_core::query_tracker::QueryTracker; use fff_core::{ - DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFrecency, - SharedPicker, SharedQueryTracker, + DbHealthChecker, Error, FuzzySearchOptions, GitRecencyConfig, PaginationArgs, QueryParser, + SharedFrecency, SharedPicker, SharedQueryTracker, }; use mimalloc::MiMalloc; use mlua::prelude::*; @@ -77,7 +77,33 @@ pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult { Ok(true) } -pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult { +/// Parse git recency config from an optional Lua table. +/// Returns defaults if the table is nil or missing fields. +fn parse_git_recency_config(table: Option) -> GitRecencyConfig { + let Some(table) = table else { + return GitRecencyConfig::default(); + }; + + let enabled: bool = table.get("enabled").unwrap_or(true); + if !enabled { + return GitRecencyConfig { + max_commits: 0, + max_files_per_commit: 0, + max_bonus: 0, + }; + } + + GitRecencyConfig { + max_commits: table.get("max_commits").unwrap_or(10), + max_files_per_commit: table.get("max_files_per_commit").unwrap_or(50), + max_bonus: table.get("max_bonus").unwrap_or(15), + } +} + +pub fn init_file_picker( + _: &Lua, + (base_path, git_recency_table): (String, Option), +) -> LuaResult { { let guard = FILE_PICKER .read() @@ -88,11 +114,14 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult { } } + let git_recency_config = parse_git_recency_config(git_recency_table); + FilePicker::new_with_shared_state( base_path, false, Arc::clone(&FILE_PICKER), Arc::clone(&FRECENCY), + git_recency_config, ) .into_lua_result()?; @@ -100,6 +129,15 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult { } fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> { + // Read the git recency config from the existing picker before stopping it + let git_recency_config = { + let guard = FILE_PICKER.read().with_lock_error(Error::AcquireItemLock)?; + guard + .as_ref() + .map(|p| p.git_recency_config()) + .unwrap_or_default() + }; + // Stop existing picker { let mut guard = FILE_PICKER @@ -116,6 +154,7 @@ fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> { false, Arc::clone(&FILE_PICKER), Arc::clone(&FRECENCY), + git_recency_config, )?; Ok(()) diff --git a/crates/fff-nvim/src/lua_types.rs b/crates/fff-nvim/src/lua_types.rs index 9f885820..d33fe93e 100644 --- a/crates/fff-nvim/src/lua_types.rs +++ b/crates/fff-nvim/src/lua_types.rs @@ -53,6 +53,7 @@ fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult { )?; table.set("total_frecency_score", item.total_frecency_score)?; table.set("git_status", format_git_status(item.git_status))?; + table.set("git_recency_score", item.git_recency_score)?; table.set("is_binary", item.is_binary)?; Ok(LuaValue::Table(table)) } @@ -64,6 +65,7 @@ fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult { table.set("filename_bonus", score.filename_bonus)?; table.set("special_filename_bonus", score.special_filename_bonus)?; table.set("frecency_boost", score.frecency_boost)?; + table.set("git_recency_boost", score.git_recency_boost)?; table.set("distance_penalty", score.distance_penalty)?; table.set("current_file_penalty", score.current_file_penalty)?; table.set("combo_match_boost", score.combo_match_boost)?; diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index 86e173b7..7af96e7d 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -1,4 +1,4 @@ -*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 28 +*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 06 ============================================================================== Table of Contents *fff.nvim-table-of-contents* @@ -463,6 +463,31 @@ with your own custom highlight groups to match your colorscheme. < +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 diff --git a/lua/fff/conf.lua b/lua/fff/conf.lua index aa5bd55b..61c682c3 100644 --- a/lua/fff/conf.lua +++ b/lua/fff/conf.lua @@ -311,6 +311,13 @@ local function init() -- Git integration git = { status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column) + -- Git recency: boost files that appear in recent commits on the current branch + recency = { + enabled = true, + max_commits = 10, -- Number of recent commits to analyze + max_files_per_commit = 50, -- Ignore commits touching more files than this (filters merge commits, bulk refactors) + max_bonus = 15, -- Maximum additive score bonus for files in the most recent commit + }, }, debug = { enabled = false, -- Set to true to show scores in the UI diff --git a/lua/fff/core.lua b/lua/fff/core.lua index 23799c1f..5c47630e 100644 --- a/lua/fff/core.lua +++ b/lua/fff/core.lua @@ -96,7 +96,7 @@ M.ensure_initialized = function() local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true) if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end - ok, result = pcall(fuzzy.init_file_picker, config.base_path) + ok, result = pcall(fuzzy.init_file_picker, config.base_path, config.git and config.git.recency or nil) if not ok then vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR) return fuzzy