use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::WalkBuilder;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub struct GitignoreFilter {
matchers: HashMap<PathBuf, Gitignore>,
global_matcher: Gitignore,
exclude_matcher: Gitignore,
repo_root: Box<Path>,
}
impl GitignoreFilter {
pub fn new(repo_root: &Path) -> Self {
let matchers = Self::build_matchers(repo_root);
let exclude_matcher = Self::build_exclude_matcher(repo_root);
let global_matcher = Self::build_global_matcher();
Self {
matchers,
global_matcher,
exclude_matcher,
repo_root: repo_root.into(),
}
}
pub fn rebuild(&mut self) {
self.matchers = Self::build_matchers(&self.repo_root);
self.exclude_matcher = Self::build_exclude_matcher(&self.repo_root);
}
pub fn is_ignored(&self, path: &Path) -> bool {
let relative = path.strip_prefix(&*self.repo_root).unwrap_or(path);
let is_dir = path.is_dir();
let mut dirs_to_check: Vec<&Path> = Vec::new();
let mut current = relative;
while let Some(parent) = current.parent() {
dirs_to_check.push(parent);
current = parent;
}
for dir in &dirs_to_check {
if let Some(matcher) = self.matchers.get(*dir) {
let rel_to_matcher = if dir.as_os_str().is_empty() {
relative
} else {
relative.strip_prefix(dir).unwrap_or(relative)
};
match matcher.matched_path_or_any_parents(rel_to_matcher, is_dir) {
ignore::Match::Ignore(_) => return true,
ignore::Match::Whitelist(_) => return false, ignore::Match::None => continue,
}
}
}
match self
.exclude_matcher
.matched_path_or_any_parents(relative, is_dir)
{
ignore::Match::Ignore(_) => return true,
ignore::Match::Whitelist(_) => return false,
ignore::Match::None => {}
}
matches!(
self.global_matcher
.matched_path_or_any_parents(relative, is_dir),
ignore::Match::Ignore(_)
)
}
pub fn is_gitignore_file(path: &Path) -> bool {
let file_name = path.file_name().and_then(|n| n.to_str());
matches!(file_name, Some(".gitignore")) || path.ends_with(".git/info/exclude")
}
fn build_matchers(repo_root: &Path) -> HashMap<PathBuf, Gitignore> {
let mut matchers = HashMap::new();
for entry in WalkBuilder::new(repo_root)
.hidden(false) .git_ignore(true) .git_global(true)
.git_exclude(true)
.filter_entry(|e| e.file_name() != ".git")
.build()
.flatten()
{
let path = entry.path();
if path.file_name() == Some(OsStr::new(".gitignore"))
&& let Some((dir, matcher)) = Self::build_matcher_for_gitignore(path, repo_root)
{
matchers.insert(dir, matcher);
}
}
matchers
}
fn build_matcher_for_gitignore(
gitignore_path: &Path,
repo_root: &Path,
) -> Option<(PathBuf, Gitignore)> {
let abs_dir = gitignore_path.parent()?;
let rel_dir = abs_dir.strip_prefix(repo_root).unwrap_or(Path::new(""));
let mut builder = GitignoreBuilder::new(abs_dir);
let _ = builder.add(gitignore_path);
let matcher = builder.build().unwrap_or_else(|_| Gitignore::empty());
Some((rel_dir.to_path_buf(), matcher))
}
fn build_exclude_matcher(repo_root: &Path) -> Gitignore {
let exclude_path = repo_root.join(".git/info/exclude");
if exclude_path.exists() {
let mut builder = GitignoreBuilder::new(repo_root);
let _ = builder.add(&exclude_path);
builder.build().unwrap_or_else(|_| Gitignore::empty())
} else {
Gitignore::empty()
}
}
fn build_global_matcher() -> Gitignore {
let builder = GitignoreBuilder::new(PathBuf::new());
let (gitignore, _err) = builder.build_global();
gitignore
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_repo() -> TempDir {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".git/info")).unwrap();
temp
}
#[test]
fn test_basic_gitignore_matching() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".gitignore"), "*.log\ntarget/\n").unwrap();
let filter = GitignoreFilter::new(path);
assert!(filter.is_ignored(&path.join("debug.log")));
assert!(filter.is_ignored(&path.join("target/release/binary")));
assert!(!filter.is_ignored(&path.join("src/main.rs")));
}
#[test]
fn test_negation_patterns() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".gitignore"), "*.log\n!important.log\n").unwrap();
let filter = GitignoreFilter::new(path);
assert!(filter.is_ignored(&path.join("debug.log")));
assert!(!filter.is_ignored(&path.join("important.log")));
}
#[test]
fn test_subdirectory_patterns() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".gitignore"), "subdir/*.tmp\n").unwrap();
fs::create_dir_all(path.join("subdir")).unwrap();
let filter = GitignoreFilter::new(path);
assert!(filter.is_ignored(&path.join("subdir/file.tmp")));
assert!(!filter.is_ignored(&path.join("file.tmp")));
}
#[test]
fn test_git_info_exclude() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".git/info/exclude"), "*.secret\n").unwrap();
let filter = GitignoreFilter::new(path);
assert!(filter.is_ignored(&path.join("passwords.secret")));
assert!(!filter.is_ignored(&path.join("passwords.txt")));
}
#[test]
fn test_rebuild_on_gitignore_change() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".gitignore"), "").unwrap();
let mut filter = GitignoreFilter::new(path);
assert!(!filter.is_ignored(&path.join("test.log")));
fs::write(path.join(".gitignore"), "*.log\n").unwrap();
filter.rebuild();
assert!(filter.is_ignored(&path.join("test.log")));
}
#[test]
fn test_is_gitignore_file() {
assert!(GitignoreFilter::is_gitignore_file(Path::new(".gitignore")));
assert!(GitignoreFilter::is_gitignore_file(Path::new(
"subdir/.gitignore"
)));
assert!(GitignoreFilter::is_gitignore_file(Path::new(
".git/info/exclude"
)));
assert!(GitignoreFilter::is_gitignore_file(Path::new(
"/repo/.git/info/exclude"
)));
assert!(!GitignoreFilter::is_gitignore_file(Path::new("src/main.rs")));
assert!(!GitignoreFilter::is_gitignore_file(Path::new(
".gitignore.bak"
)));
}
#[test]
fn test_directory_patterns() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".gitignore"), "node_modules/\n").unwrap();
fs::create_dir_all(path.join("node_modules/pkg")).unwrap();
let filter = GitignoreFilter::new(path);
assert!(filter.is_ignored(&path.join("node_modules")));
assert!(filter.is_ignored(&path.join("node_modules/pkg/index.js")));
}
#[test]
fn test_empty_repo_no_gitignore() {
let temp = create_test_repo();
let path = temp.path();
let filter = GitignoreFilter::new(path);
assert!(!filter.is_ignored(&path.join("any_file.txt")));
assert!(!filter.is_ignored(&path.join("node_modules/pkg.js")));
}
#[test]
fn test_nested_gitignore_scoping() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".gitignore"), "*.log\n").unwrap();
fs::create_dir_all(path.join("subdir")).unwrap();
fs::write(path.join("subdir/.gitignore"), "*.txt\n").unwrap();
let filter = GitignoreFilter::new(path);
assert!(filter.is_ignored(&path.join("test.log")));
assert!(filter.is_ignored(&path.join("subdir/test.log")));
assert!(
!filter.is_ignored(&path.join("test.txt")),
"Root .txt should NOT be ignored"
);
assert!(
filter.is_ignored(&path.join("subdir/test.txt")),
"subdir .txt SHOULD be ignored"
);
}
#[test]
fn test_nested_gitignore_negation() {
let temp = create_test_repo();
let path = temp.path();
fs::write(path.join(".gitignore"), "*.log\n").unwrap();
fs::create_dir_all(path.join("subdir")).unwrap();
fs::write(path.join("subdir/.gitignore"), "!important.log\n").unwrap();
let filter = GitignoreFilter::new(path);
assert!(filter.is_ignored(&path.join("test.log")));
assert!(filter.is_ignored(&path.join("subdir/test.log")));
assert!(
!filter.is_ignored(&path.join("subdir/important.log")),
"Nested negation should un-ignore important.log"
);
}
#[test]
fn test_deeply_nested_gitignore() {
let temp = create_test_repo();
let path = temp.path();
fs::create_dir_all(path.join("a/b/c")).unwrap();
fs::write(path.join("a/.gitignore"), "*.a\n").unwrap();
fs::write(path.join("a/b/.gitignore"), "*.b\n").unwrap();
fs::write(path.join("a/b/c/.gitignore"), "*.c\n").unwrap();
let filter = GitignoreFilter::new(path);
assert!(!filter.is_ignored(&path.join("test.a")));
assert!(filter.is_ignored(&path.join("a/test.a")));
assert!(filter.is_ignored(&path.join("a/b/test.a")));
assert!(filter.is_ignored(&path.join("a/b/c/test.a")));
assert!(!filter.is_ignored(&path.join("test.b")));
assert!(!filter.is_ignored(&path.join("a/test.b")));
assert!(filter.is_ignored(&path.join("a/b/test.b")));
assert!(filter.is_ignored(&path.join("a/b/c/test.b")));
assert!(!filter.is_ignored(&path.join("test.c")));
assert!(!filter.is_ignored(&path.join("a/test.c")));
assert!(!filter.is_ignored(&path.join("a/b/test.c")));
assert!(filter.is_ignored(&path.join("a/b/c/test.c")));
}
#[test]
fn test_nested_gitignore_rebuild() {
let temp = create_test_repo();
let path = temp.path();
fs::create_dir_all(path.join("subdir")).unwrap();
fs::write(path.join(".gitignore"), "").unwrap();
let mut filter = GitignoreFilter::new(path);
assert!(!filter.is_ignored(&path.join("subdir/test.txt")));
fs::write(path.join("subdir/.gitignore"), "*.txt\n").unwrap();
filter.rebuild();
assert!(filter.is_ignored(&path.join("subdir/test.txt")));
}
#[test]
fn test_nested_gitignore_directory_pattern() {
let temp = create_test_repo();
let path = temp.path();
fs::create_dir_all(path.join("subdir/build/output")).unwrap();
fs::write(path.join("subdir/.gitignore"), "build/\n").unwrap();
let filter = GitignoreFilter::new(path);
assert!(!filter.is_ignored(&path.join("build")));
assert!(!filter.is_ignored(&path.join("build/output/file")));
assert!(filter.is_ignored(&path.join("subdir/build")));
assert!(filter.is_ignored(&path.join("subdir/build/output")));
assert!(filter.is_ignored(&path.join("subdir/build/output/file.txt")));
}
}