diff --git a/crates/fff-core/src/ignore.rs b/crates/fff-core/src/ignore.rs index 4589285d2..7159621c8 100644 --- a/crates/fff-core/src/ignore.rs +++ b/crates/fff-core/src/ignore.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; /// Directories excluded when walking a non-git root. Entries are `cfg`-gated /// so a single iteration covers standard + platform-specific overrides. @@ -64,6 +64,41 @@ pub(crate) const IGNORED_DIRS: &[&str] = &[ "AppData/Roaming", ]; +/// Resolves git's `core.excludesFile` through the repo's own config chain, so +/// repo-local, global and system settings all win in git's precedence order. +pub(crate) fn git_excludes_file(base_path: &Path) -> Option { + let config = git2::Repository::discover(base_path) + .and_then(|repo| repo.config()) + .or_else(|_| git2::Config::open_default()) + .ok()?; + + // `get_path` expands a leading `~` for us; a configured-but-absent file is + // legal in git and simply matches nothing. + let path = config.get_path("core.excludesFile").ok()?; + path.is_file().then_some(path) +} + +/// `core.excludesFile` contents as gitignore patterns, comments stripped. +#[cfg(feature = "zlob")] +pub(crate) fn git_excludes_patterns(base_path: &Path) -> Vec { + let Some(path) = git_excludes_file(base_path) else { + return Vec::new(); + }; + + match std::fs::read_to_string(&path) { + Ok(contents) => contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_owned) + .collect(), + Err(e) => { + tracing::warn!(?e, ?path, "core.excludesFile unreadable; not applied"); + Vec::new() + } + } +} + #[cfg(all(not(feature = "zlob"), feature = "ripgrep"))] pub(crate) fn non_git_repo_overrides(base_path: &Path) -> Option { use ignore::overrides::OverrideBuilder; diff --git a/crates/fff-core/src/walk/mod.rs b/crates/fff-core/src/walk/mod.rs index e163d3a61..32790376b 100644 --- a/crates/fff-core/src/walk/mod.rs +++ b/crates/fff-core/src/walk/mod.rs @@ -145,4 +145,56 @@ mod tests { assert!(rules.is_ignored(Path::new("debug.log"))); assert!(!rules.is_ignored(Path::new("Cargo.toml"))); } + + // Both backends must honor git's core.excludesFile, not just repo-local + // .gitignore/.ignore (#874). + #[test] + fn honors_git_excludes_file() { + use std::process::Command; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::create_dir_all(root.join(".worktrees/wt1")).unwrap(); + fs::write(root.join("src/hit.ts"), "x").unwrap(); + fs::write(root.join(".worktrees/wt1/hit.ts"), "x").unwrap(); + + let excludes = root.join("global_ignore"); + fs::write(&excludes, "# comment\n\n.worktrees/\n").unwrap(); + let git = |args: &[&std::ffi::OsStr]| { + assert!( + Command::new("git") + .args(args) + .current_dir(root) + .status() + .unwrap() + .success() + ); + }; + git(&["init".as_ref(), "-q".as_ref()]); + git(&[ + "config".as_ref(), + "core.excludesFile".as_ref(), + excludes.as_ref(), + ]); + + let counter = Arc::new(AtomicUsize::new(0)); + let out = walk_collect_files(root, true, false, 1, &counter).unwrap(); + let names: Vec = out.pairs.iter().map(|(_, rel)| rel.clone()).collect(); + + assert!(names.iter().any(|n| n.ends_with("src/hit.ts")), "{names:?}"); + assert!( + !names.iter().any(|n| n.contains(".worktrees")), + "core.excludesFile not honored: {names:?}" + ); + + // The watcher reuses these rules, so excluded dirs must stay excluded + // on incremental updates too. + #[cfg(feature = "zlob")] + { + let rules = out.ignore_rules.expect("zlob surfaces ignore rules"); + assert!(rules.is_ignored(std::path::Path::new(".worktrees/"))); + assert!(!rules.is_ignored(std::path::Path::new("src/hit.ts"))); + } + } } diff --git a/crates/fff-core/src/walk/ripgrep.rs b/crates/fff-core/src/walk/ripgrep.rs index 3f3ca1bc5..043d411d2 100644 --- a/crates/fff-core/src/walk/ripgrep.rs +++ b/crates/fff-core/src/walk/ripgrep.rs @@ -32,6 +32,17 @@ pub(crate) fn walk_collect_files( walk_builder.overrides(overrides); } + // `git_global` above only consults the user-level gitconfig; resolve + // core.excludesFile through the repo's config chain instead (#874). + if is_git_repo && let Some(excludes) = crate::ignore::git_excludes_file(base_path) { + // Roots the matcher at the walk root, which is where git anchors + // core.excludesFile patterns. Must precede `add_ignore`. + walk_builder.current_dir(base_path); + if let Some(e) = walk_builder.add_ignore(&excludes) { + tracing::warn!(?e, ?excludes, "core.excludesFile not fully applied"); + } + } + let walker = walk_builder.build_parallel(); // Single lock for both collections: every entry is either a file or a diff --git a/crates/fff-core/src/walk/zlob.rs b/crates/fff-core/src/walk/zlob.rs index 98eddf1fc..d80a4b44e 100644 --- a/crates/fff-core/src/walk/zlob.rs +++ b/crates/fff-core/src/walk/zlob.rs @@ -36,9 +36,21 @@ 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) + // zlob reads nested .gitignore/.ignore itself but never git's + // core.excludesFile, so layer that in as root-level extra rules (#874). + let global_excludes = if is_git_repo { + crate::ignore::git_excludes_patterns(base_path) + } else { + Vec::new() + }; + let extra_ignore: Vec<&str> = if is_git_repo { + global_excludes.iter().map(String::as_str).collect() + } else { + IGNORED_DIRS.to_vec() + }; + + if !extra_ignore.is_empty() + && let Err(e) = builder.extra_ignore(&extra_ignore) { // Interior NUL in one of the extra_ignore patterns would fail // here — treat as if no extras were supplied rather than