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
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ ignore = "0.4.22"
memmap2 = "0.9"
mimalloc = "0.1.47"
signal-hook-registry = "1.4"
zlob = { version = "=1.6.1" }
zlob = { git = "https://github.com/celados/zlob", rev = "a64a9bc87b1d4820ce1e4e2406692935be3b5078" }

mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.11.0", features = ["match_end_col"] }
Expand Down
8 changes: 8 additions & 0 deletions crates/fff-core/src/file_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ pub(crate) struct FileSync {
/// Ignore rules the walker assembled (zlob backend only). Shared with the
/// background watcher so filesystem events can be filtered without libgit2.
pub(crate) ignore_rules: Option<Arc<crate::walk::WalkIgnoreRules>>,
pub(crate) policy_sources: Arc<Vec<PathBuf>>,
}

impl FileSync {
Expand All @@ -142,6 +143,7 @@ impl FileSync {
bigram_overlay: None,
chunked_paths: None,
ignore_rules: None,
policy_sources: Arc::new(Vec::new()),
}
}

Expand Down Expand Up @@ -636,6 +638,10 @@ impl FilePicker {
self.sync_data.ignore_rules.clone()
}

pub(crate) fn policy_sources(&self) -> Arc<Vec<PathBuf>> {
Arc::clone(&self.sync_data.policy_sources)
}

pub fn has_mmap_cache(&self) -> bool {
self.enable_mmap_cache
}
Expand Down Expand Up @@ -2028,6 +2034,7 @@ impl FileSync {
synced_files_count,
)?;
let ignore_rules = walk_output.ignore_rules.take().map(Arc::new);
let policy_sources = Arc::new(walk_output.policy_sources);
let mut pairs = walk_output.pairs;

// Sort by (dir_part, filename). This groups files by their directory
Expand Down Expand Up @@ -2131,6 +2138,7 @@ impl FileSync {
bigram_overlay: None,
chunked_paths: Some(Arc::new(chunked_paths)),
ignore_rules,
policy_sources,
})
}
}
Expand Down
118 changes: 117 additions & 1 deletion crates/fff-core/src/ignore.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,84 @@
use std::path::Path;
use git2::{Config, Repository};
use std::path::{Path, PathBuf};

#[derive(Debug, Default)]
pub(crate) struct GitIgnorePolicy {
pub(crate) base_document: String,
pub(crate) ignore_files: Vec<PathBuf>,
pub(crate) sources: Vec<PathBuf>,
}

impl GitIgnorePolicy {
pub(crate) fn discover(base_path: &Path) -> Self {
let repo = Repository::discover(base_path).ok();
// User excludes are process-wide Git policy, not repository metadata.
// `open_default` keeps non-Git vaults on the same contract as Git roots.
let global = repo
.as_ref()
.map(Repository::config)
.unwrap_or_else(Config::open_default)
.and_then(|mut config| config.snapshot())
.and_then(|config| config.get_path("core.excludesFile"))
.ok()
.or_else(default_global_excludes_path);

let mut policy = Self::default();
if let Some(path) = global {
policy.add_source(path);
}
if let Some(repo) = &repo {
// Linked worktrees share this file with the primary worktree.
policy.add_source(repo.commondir().join("info/exclude"));
}
policy.sources.extend(config_sources(repo.as_ref()));
policy.sources.sort_unstable();
policy.sources.dedup();
policy
}

fn add_source(&mut self, path: PathBuf) {
if let Ok(content) = std::fs::read_to_string(&path) {
self.base_document.push_str(&content);
if !content.ends_with('\n') {
self.base_document.push('\n');
}
self.ignore_files.push(path.clone());
}
self.sources.push(path);
}

#[cfg(feature = "zlob")]
pub(crate) fn patterns(&self) -> impl Iterator<Item = &str> {
self.base_document.lines()
}
}

fn default_global_excludes_path() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".config")))
.map(|config| config.join("git/ignore"))
}

fn config_sources(repo: Option<&Repository>) -> Vec<PathBuf> {
let mut paths = [
Config::find_system().ok(),
Config::find_global().ok(),
Config::find_xdg().ok(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
if let Some(repo) = repo {
paths.push(repo.commondir().join("config"));
// extensions.worktreeConfig stores per-worktree overrides here.
paths.push(repo.path().join("config.worktree"));
}
paths.sort_unstable();
paths.dedup();
paths
}

/// Directories excluded when walking a non-git root. Entries are `cfg`-gated
/// so a single iteration covers standard + platform-specific overrides.
Expand Down Expand Up @@ -66,3 +146,39 @@ pub(crate) fn is_non_code_directory(path: &Path) -> bool {
path_str.contains(dir)
})
}

#[cfg(test)]
mod tests {
use super::GitIgnorePolicy;
use std::fs;

#[test]
fn policy_document_orders_global_before_info() {
let dir = tempfile::tempdir().unwrap();
let global = dir.path().join("global-ignore");
let info = dir.path().join("info-exclude");
fs::write(&global, "*.tmp").unwrap();
fs::write(&info, "!keep.tmp\n").unwrap();

let mut policy = GitIgnorePolicy::default();
policy.add_source(global.clone());
policy.add_source(info.clone());

assert_eq!(policy.base_document, "*.tmp\n!keep.tmp\n");
assert_eq!(policy.ignore_files, vec![global.clone(), info.clone()]);
assert_eq!(policy.sources, vec![global, info]);
}

#[test]
fn missing_policy_source_is_watched_but_not_loaded() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("future-ignore");

let mut policy = GitIgnorePolicy::default();
policy.add_source(missing.clone());

assert!(policy.base_document.is_empty());
assert!(policy.ignore_files.is_empty());
assert_eq!(policy.sources, vec![missing]);
}
}
7 changes: 7 additions & 0 deletions crates/fff-core/src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,4 +395,11 @@ fn rescubscribe_watcher_post_scan(shared_picker: &SharedFilePicker) {
watcher.request_watch_dir(dir.to_path_buf());
std::ops::ControlFlow::Continue(())
});
for dir in picker
.policy_sources()
.iter()
.filter_map(|source| source.parent())
{
watcher.request_watch_policy_source_dir(dir.to_path_buf());
}
}
66 changes: 66 additions & 0 deletions crates/fff-core/src/walk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub(crate) use ripgrep::walk_collect_files;
pub(crate) struct WalkOutput {
pub(crate) pairs: Vec<(FileItem, String)>,
pub(crate) ignore_rules: Option<WalkIgnoreRules>,
pub(crate) policy_sources: Vec<std::path::PathBuf>,
}

pub(crate) struct WalkIgnoreRules {
Expand Down Expand Up @@ -143,4 +144,69 @@ mod tests {
assert!(rules.is_ignored(Path::new("debug.log")));
assert!(!rules.is_ignored(Path::new("Cargo.toml")));
}

#[test]
fn nested_negation_prevents_directory_pruning() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join(".git")).unwrap();
fs::write(root.join(".gitignore"), "*\n!*.*\n!/**/\n").unwrap();
fs::create_dir_all(root.join("sub1/sub2")).unwrap();
fs::write(root.join("top.rs"), "").unwrap();
fs::write(root.join("sub1/mid.rs"), "").unwrap();
fs::write(root.join("sub1/sub2/deep.rs"), "").unwrap();

let counter = Arc::new(AtomicUsize::new(0));
let out = walk_collect_files(root, true, false, 1, &counter).unwrap();
let names: Vec<_> = out.pairs.into_iter().map(|(_, rel)| rel).collect();

assert!(names.contains(&"top.rs".to_string()), "got {names:?}");
assert!(names.contains(&"sub1/mid.rs".to_string()), "got {names:?}");
assert!(
names.contains(&"sub1/sub2/deep.rs".to_string()),
"got {names:?}"
);
}

#[cfg(feature = "zlob")]
#[test]
fn git_exclude_layers_follow_git_precedence() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let repo = git2::Repository::init(root).unwrap();
let global = root.join("global-ignore");
fs::write(&global, "*.tmp\n*.info\n").unwrap();
fs::create_dir_all(repo.commondir().join("info")).unwrap();
fs::write(repo.commondir().join("info/exclude"), "!info-keep.tmp\n").unwrap();
fs::write(root.join(".gitignore"), "!root-keep.info\n").unwrap();
repo.config()
.unwrap()
.set_str("core.excludesFile", global.to_str().unwrap())
.unwrap();
for path in [
"drop.tmp",
"info-keep.tmp",
"drop.info",
"root-keep.info",
"visible.md",
] {
fs::write(root.join(path), "").unwrap();
}

let counter = Arc::new(AtomicUsize::new(0));
let out = walk_collect_files(root, true, false, 1, &counter).unwrap();
let names: Vec<_> = out.pairs.into_iter().map(|(_, rel)| rel).collect();

assert!(!names.contains(&"drop.tmp".to_string()), "got {names:?}");
assert!(
names.contains(&"info-keep.tmp".to_string()),
"got {names:?}"
);
assert!(!names.contains(&"drop.info".to_string()), "got {names:?}");
assert!(
names.contains(&"root-keep.info".to_string()),
"got {names:?}"
);
assert!(names.contains(&"visible.md".to_string()), "got {names:?}");
}
}
14 changes: 12 additions & 2 deletions crates/fff-core/src/walk/ripgrep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,26 @@ pub(crate) fn walk_collect_files(
threads: usize,
synced_files_count: &Arc<AtomicUsize>,
) -> crate::Result<WalkOutput> {
let policy = crate::ignore::GitIgnorePolicy::discover(base_path);
let mut walk_builder = WalkBuilder::new(base_path);
walk_builder
// this is a very important guard for the user opening ~/ or other root non-git dir
.hidden(!is_git_repo)
.git_ignore(true)
.git_exclude(true)
.git_global(true)
// User and repository-wide excludes enter through GitIgnorePolicy so
// scan and watcher rebuild from one precedence-ordered source list.
.git_exclude(false)
.git_global(false)
.ignore(true)
.follow_links(follow_symlinks)
.threads(threads);

for path in &policy.ignore_files {
if let Some(error) = walk_builder.add_ignore(path) {
tracing::warn!(?error, path = %path.display(), "Failed to load Git ignore policy source");
}
}

if !is_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) {
walk_builder.overrides(overrides);
}
Expand Down Expand Up @@ -68,5 +77,6 @@ pub(crate) fn walk_collect_files(
Ok(WalkOutput {
pairs: pairs.into_inner(),
ignore_rules: None,
policy_sources: policy.sources,
})
}
9 changes: 9 additions & 0 deletions crates/fff-core/src/walk/zlob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub(crate) fn walk_collect_files(
threads: usize,
synced_files_count: &Arc<AtomicUsize>,
) -> crate::Result<WalkOutput> {
let policy = crate::ignore::GitIgnorePolicy::discover(base_path);
// gitignore on; skip hidden on non-git roots (so `~/` doesn't recurse into
// ~/.cache, ~/.config, etc.); optionally follow symlinks.
let mut flags = WalkFlags::GITIGNORE;
Expand All @@ -38,6 +39,13 @@ pub(crate) fn walk_collect_files(
// Bulk-fetch the only metadata FileItem needs; zlob never stats more.
.metadata(WalkMetadata::SIZE | WalkMetadata::MTIME);

let base_patterns = policy.patterns().collect::<Vec<_>>();
if !base_patterns.is_empty() {
builder
.base_ignore(&base_patterns)
.map_err(|e| crate::Error::WalkFailed(format!("base ignore: {e:?}")))?;
}

if !is_git_repo
&& !IGNORED_DIRS.is_empty()
&& let Err(e) = builder.extra_ignore(IGNORED_DIRS)
Expand Down Expand Up @@ -107,5 +115,6 @@ pub(crate) fn walk_collect_files(
Ok(WalkOutput {
pairs,
ignore_rules,
policy_sources: policy.sources,
})
}
Loading