From 6350f1210c83bdbf31397847a779545242fd6290 Mon Sep 17 00:00:00 2001 From: Willem Kokke Date: Sat, 1 Aug 2026 19:13:22 +0100 Subject: [PATCH 1/2] Honour repository boundaries when searching for ignore files The upward search for .gitignore/.ignore/.git/info/exclude treated the directory tree as one hierarchy: from a linked worktree (whose .git is a gitdir pointer file, not a directory) it sailed past the working-tree root, found the enclosing checkout's .git/info/exclude, and applied its patterns rooted at the enclosing checkout. A pattern like '.claude/worktrees/' in a main checkout's exclude file (how several agent tools register their worktrees) then matched every file inside the worktree, and pyrefly skipped all of the project's includes with 'No Python files matched'. git's own semantics: a directory carrying a .git entry (file or directory) is a working-tree root; an outer repository's ignore rules never apply inside it, and the repository's shared info/exclude applies to each worktree relative to that worktree's own root. The search now walks upward collecting the nearest .gitignore and .ignore, stops at the first directory with a .git entry, and resolves info/exclude through the gitdir pointer and commondir for linked worktrees -- rooted at the working tree being checked. --- crates/pyrefly_util/src/globs.rs | 200 ++++++++++++++++++++++++++----- 1 file changed, 171 insertions(+), 29 deletions(-) diff --git a/crates/pyrefly_util/src/globs.rs b/crates/pyrefly_util/src/globs.rs index 0097637089..af79bf0e3a 100644 --- a/crates/pyrefly_util/src/globs.rs +++ b/crates/pyrefly_util/src/globs.rs @@ -6,7 +6,6 @@ */ use std::ffi::OsStr; -use std::ffi::OsString; use std::fmt; use std::fmt::Debug; use std::fmt::Display; @@ -15,8 +14,6 @@ use std::num::NonZeroUsize; use std::path::Component; use std::path::Path; use std::path::PathBuf; -use std::sync::Arc; -use std::sync::LazyLock; use std::thread::available_parallelism; use anyhow::Context; @@ -40,25 +37,71 @@ use crate::includes::Includes; use crate::lock::Mutex; use crate::prelude::SliceExt; use crate::prelude::VecExt; -use crate::upward_search::UpwardSearch; -static IGNORE_FILES_SEARCH: LazyLock>>> = - LazyLock::new(|| { - [".gitignore", ".ignore", ".git/info/exclude"] - .iter() - .map(|f| { - UpwardSearch::new(vec![OsString::from(f)], |p| { - let mut ignore_root = p.to_path_buf(); - ignore_root.pop(); - if *f == ".git/info/exclude" { - ignore_root.pop(); - ignore_root.pop(); - } - Arc::new((p.to_path_buf(), ignore_root)) - }) - }) - .collect::>() - }); +/// The ignore files that govern `root`, as `(file, root the patterns are +/// relative to)` pairs, honouring repository boundaries the way git does. +/// Walking upward from `root`: +/// +/// * the nearest `.gitignore` and the nearest `.ignore` apply, each rooted +/// at its own directory; +/// * the first directory carrying a `.git` entry is the working-tree root, +/// and the walk stops there: an ignore file above it belongs to a +/// different, enclosing repository, and git never applies an outer +/// repository's rules inside an inner working tree; +/// * the repository's `info/exclude` applies rooted at the working-tree +/// root. For a linked worktree `.git` is a *file* (`gitdir: ...`) and the +/// exclude file lives in the shared common dir -- resolved through +/// `commondir`, but still rooted at *this* worktree: a pattern like +/// `worktrees/` in a main checkout's exclude file names the worktree +/// directories as seen from the main checkout, not the files inside them. +fn find_ignore_files(root: &Path) -> Vec<(PathBuf, PathBuf)> { + let mut gitignore = None; + let mut dotignore = None; + let mut info_exclude = None; + for dir in root.absolutize().ancestors() { + if gitignore.is_none() { + let candidate = dir.join(".gitignore"); + if candidate.is_file() { + gitignore = Some((candidate, dir.to_path_buf())); + } + } + if dotignore.is_none() { + let candidate = dir.join(".ignore"); + if candidate.is_file() { + dotignore = Some((candidate, dir.to_path_buf())); + } + } + let git = dir.join(".git"); + if git.exists() { + info_exclude = git_common_dir(&git) + .map(|common| common.join("info").join("exclude")) + .filter(|exclude| exclude.is_file()) + .map(|exclude| (exclude, dir.to_path_buf())); + break; + } + } + [gitignore, dotignore, info_exclude] + .into_iter() + .flatten() + .collect() +} + +/// The repository directory a `.git` entry denotes: itself when it is a +/// directory, or -- for a linked worktree or submodule, where it is a +/// `gitdir: ...` pointer file -- the *common* directory the pointer leads to, +/// following `commondir` when the private per-worktree directory carries one. +fn git_common_dir(git: &Path) -> Option { + if git.is_dir() { + return Some(git.to_path_buf()); + } + let pointer = std::fs::read_to_string(git).ok()?; + let target = pointer.lines().next()?.strip_prefix("gitdir:")?.trim(); + let private = Path::new(target).absolutize_from(git.parent()?); + match std::fs::read_to_string(private.join("commondir")) { + Ok(common) => Some(Path::new(common.trim()).absolutize_from(&private)), + Err(_) => Some(private), + } +} const PYTHON_FILE_EXTENSIONS: &[&str] = &["py", "pyi", "pyw", "ipynb"]; @@ -885,23 +928,19 @@ impl GlobFilter { } pub fn ignore_files(root: &Path) -> (Vec, Vec, Vec) { - let found_ignores = IGNORE_FILES_SEARCH - .iter() - .filter_map(|s| s.directory_absolute(root)); let mut errors = vec![]; let mut ignores = vec![]; let mut ignore_paths = vec![]; - for item in found_ignores { - let (ignore_file, ignore_root) = &*item; - let mut builder = GitignoreBuilder::new(ignore_root); - if let Some(error) = builder.add(ignore_file) { + for (ignore_file, ignore_root) in find_ignore_files(root) { + let mut builder = GitignoreBuilder::new(&ignore_root); + if let Some(error) = builder.add(&ignore_file) { errors.push(error.into()); } match builder.build() { Ok(ignore) => ignores.push(ignore), Err(error) => errors.push(error.into()), } - ignore_paths.push(ignore_file.to_owned()); + ignore_paths.push(ignore_file); } (ignores, errors, ignore_paths) } @@ -1819,6 +1858,109 @@ mod tests { assert!(!filter.is_excluded(&root.join("my_file.py"))); } + #[test] + fn test_ignore_files_stop_at_the_enclosing_repository() { + // A project that is its own repository: ignore files above its + // working-tree root belong to a different (enclosing) repository, + // and git never applies an outer repository's rules inside an + // inner working tree. + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path(); + TestPath::setup_test_directory( + root, + vec![ + TestPath::file_with_contents(".gitignore", "**/*.outer_exclude"), + TestPath::dir( + ".git", + vec![TestPath::dir( + "info", + vec![TestPath::file_with_contents("exclude", "inner/")], + )], + ), + TestPath::dir( + "inner", + vec![ + TestPath::dir( + ".git", + vec![TestPath::dir( + "info", + vec![TestPath::file_with_contents("exclude", "generated/")], + )], + ), + TestPath::file("pyrefly.toml"), + ], + ), + ], + ); + + let inner = root.join("inner"); + let filter = GlobFilter::new(Globs::empty(), Some(&inner), HiddenDirFilter::Disabled); + + assert_eq!(filter.ignore_paths, vec![inner.join(".git/info/exclude")]); + // The outer `.gitignore` and the outer repository's `inner/` exclude + // both name this tree from outside; neither governs files within it. + assert!(!filter.is_excluded(&inner.join("my_file.outer_exclude"))); + assert!(!filter.is_excluded(&inner.join("src/my_file.py"))); + // The inner repository's own exclude file still applies. + assert!(filter.is_excluded(&inner.join("generated/my_file.py"))); + } + + #[test] + fn test_worktree_reads_the_common_exclude_rooted_at_the_worktree() { + // A linked worktree: `.git` is a `gitdir: ...` pointer file, and the + // repository's `info/exclude` lives in the shared common dir. Its + // patterns apply *relative to this worktree's root* -- a pattern + // naming the worktree's own location as seen from the main checkout + // (`.claude/worktrees/`, the shape that hid every include from a + // checkout under such a path) must not match the files inside it. + let tempdir = tempfile::tempdir().unwrap(); + let root = tempdir.path(); + TestPath::setup_test_directory( + root, + vec![ + TestPath::file_with_contents(".gitignore", "**/*.outer_exclude"), + TestPath::dir( + ".git", + vec![ + TestPath::dir( + "info", + vec![TestPath::file_with_contents( + "exclude", + "**/.claude/worktrees/\ngenerated/", + )], + ), + TestPath::dir("worktrees", vec![TestPath::dir("wt", vec![])]), + ], + ), + TestPath::dir( + ".claude", + vec![TestPath::dir( + "worktrees", + vec![TestPath::dir("wt", vec![TestPath::file("pyrefly.toml")])], + )], + ), + ], + ); + let private = root.join(".git/worktrees/wt"); + std::fs::write(private.join("commondir"), "../..\n").unwrap(); + let worktree = root.join(".claude/worktrees/wt"); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", private.display()), + ) + .unwrap(); + + let filter = GlobFilter::new(Globs::empty(), Some(&worktree), HiddenDirFilter::Disabled); + + // The common exclude file is found through the pointer, rooted here. + assert_eq!(filter.ignore_paths, vec![root.join(".git/info/exclude")]); + assert!(!filter.is_excluded(&worktree.join("src/my_file.py"))); + assert!(!filter.is_excluded(&worktree.join("my_file.outer_exclude"))); + // Per-worktree semantics: the same file's patterns still apply to + // paths as seen from this worktree's own root. + assert!(filter.is_excluded(&worktree.join("generated/my_file.py"))); + } + #[test] fn test_explicitly_specified_files_without_extension() { let tempdir = tempfile::tempdir().unwrap(); From 8bcb2abd3b42ef2a07a13bd83f700fd802daecd4 Mon Sep 17 00:00:00 2001 From: Willem Kokke Date: Sat, 1 Aug 2026 19:15:53 +0100 Subject: [PATCH 2/2] Filter hidden directories relative to every include root The RelativeTo hidden-directory allowance carried only import_root, so for a src-layout project (import_root = src/) any include outside it -- tests/check.py -- fell back to absolute-path component checking, where a checkout under a hidden directory (~/.codex/worktrees/..., .claude/worktrees/...) has every path hidden and the include was skipped. The roots are now every include root plus the import root. Also decouples the allowance from use_ignore_files: the root list was only populated when ignore files were enabled, so --use-ignore-files= false degraded the filter to HiddenDirFilter::All and made hidden- directory filtering stricter -- disabling one exclusion mechanism silently hardened another. --- crates/pyrefly_config/src/config.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/pyrefly_config/src/config.rs b/crates/pyrefly_config/src/config.rs index 573f0cbd49..27538fc648 100644 --- a/crates/pyrefly_config/src/config.rs +++ b/crates/pyrefly_config/src/config.rs @@ -796,9 +796,24 @@ impl ConfigFile { let hidden_dir_filter = if self.disable_project_excludes_heuristics { HiddenDirFilter::Disabled } else { - match root { - Some(r) => HiddenDirFilter::RelativeTo(vec![r.to_path_buf()]), - None => HiddenDirFilter::All, + // Hidden ancestors above the project's own roots must not hide + // the project's files: check components relative to every + // include root, plus the import root. The import root alone is + // not enough — a src-layout project's `import_root` is `src/`, + // and an include outside it (`tests/check.py`) would fall back + // to the absolute path, where a checkout under a hidden + // directory (`~/.codex/worktrees/…`, `.claude/worktrees/…`) + // has every component chain hidden. Deliberately independent of + // `use_ignore_files`: turning ignore files off must not make + // hidden-directory filtering stricter. + let mut roots = includes.roots(); + if let Some(import_root) = self.import_root.as_deref() { + roots.push(import_root.to_path_buf()); + } + if roots.is_empty() { + HiddenDirFilter::All + } else { + HiddenDirFilter::RelativeTo(roots) } }; FilteredGlobs::new(includes, project_excludes, root, hidden_dir_filter) @@ -3320,7 +3335,10 @@ output-format = "omit-errors" globs(&["covered/**"]), globs(&excludes), None, - HiddenDirFilter::All, + // Hidden-directory filtering is relative to the include + // roots, so a project under a hidden directory still sees + // its own files. + HiddenDirFilter::RelativeTo(globs(&["covered/**"]).roots()), ) };