From 599b72cba509f0a7eb7aab2be52e80390261f9a9 Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:19:34 -0700 Subject: [PATCH] fix: match slash-less globs like foo* by basename (#901) Slash-less glob tokens without a leading * were matched against the full relative path, so foo* only hit root-level files. Prefix them with **/ before compiling. Closes #901 --- crates/fff-core/src/index/constraints.rs | 62 +++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/fff-core/src/index/constraints.rs b/crates/fff-core/src/index/constraints.rs index d91759fcc..04c359bd7 100644 --- a/crates/fff-core/src/index/constraints.rs +++ b/crates/fff-core/src/index/constraints.rs @@ -2,6 +2,7 @@ use fff_query_parser::{Constraint, GitStatusFilter}; use smallvec::SmallVec; +use std::borrow::Cow; use crate::git::is_modified_status; use crate::simd_path::ArenaPtr; @@ -485,7 +486,7 @@ fn precompute_masks(rest: &[&Constraint<'_>], paths: &[&str]) -> Vec> let mut out = Vec::new(); for c in rest { walk_globs(c, &mut |pattern| { - out.push(match_glob_pattern(pattern, paths)) + out.push(match_glob_pattern(&unanchor_glob(pattern), paths)) }); } out @@ -494,7 +495,9 @@ fn precompute_masks(rest: &[&Constraint<'_>], paths: &[&str]) -> Vec> fn compile_globs(rest: &[&Constraint<'_>]) -> Vec> { let mut out = Vec::new(); for c in rest { - walk_globs(c, &mut |pattern| out.push(compile_one(pattern))); + walk_globs(c, &mut |pattern| { + out.push(compile_one(&unanchor_glob(pattern))) + }); } out } @@ -509,6 +512,17 @@ fn walk_globs(c: &Constraint<'_>, f: &mut F) { } } +/// Slash-less globs match by basename (`foo*` -> `**/foo*`), like `*.ext` does. +/// Leading `*` already crosses `/`; braces stay anchored for `{src,lib}` dir alternatives. +fn unanchor_glob(pattern: &str) -> Cow<'_, str> { + let b = pattern.as_bytes(); + if b.first() == Some(&b'*') || b.contains(&b'/') || b.contains(&b'{') { + Cow::Borrowed(pattern) + } else { + Cow::Owned(format!("**/{pattern}")) + } +} + #[cfg(feature = "zlob")] pub(crate) fn compile_one(pattern: &str) -> Option { zlob::ZlobPattern::compile(pattern, GLOB_FLAGS).ok() @@ -953,4 +967,48 @@ mod tests { .collect(); assert_eq!(paths, vec!["src/main.rs"]); } + + #[test] + fn test_slash_less_glob_matches_basename() { + let arena_ptr = ArenaPtr::null(); + let items = vec![ + TestItem { + relative_path: "hash-glibc/lib/libc.so.6", + file_name: "libc.so.6", + }, + TestItem { + relative_path: "libc.so.1", + file_name: "libc.so.1", + }, + TestItem { + relative_path: "src/main.rs", + file_name: "main.rs", + }, + ]; + let run = |constraints: Vec>| -> Vec<&str> { + apply_constraints(&items, &constraints, arena_ptr, arena_ptr) + .unwrap_or_default() + .iter() + .map(|i| i.relative_path) + .collect() + }; + + let expected = vec!["hash-glibc/lib/libc.so.6", "libc.so.1"]; + // Prepass (pure glob) and inline (with pre-filter) paths. + assert_eq!(run(vec![Constraint::Glob("libc.so*")]), expected); + assert_eq!( + run(vec![ + Constraint::Glob("libc.so*"), + Constraint::Not(Box::new(Constraint::Extension("rs"))), + ]), + expected + ); + assert_eq!(run(vec![Constraint::Glob("src/*")]), vec!["src/main.rs"]); + assert_eq!( + run(vec![Constraint::Not(Box::new(Constraint::Glob( + "libc.so*" + )))]), + vec!["src/main.rs"] + ); + } }