use git2::{Repository, Status};
use ignore::WalkBuilder;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub struct GitignoreFilter {
included_files: HashSet<PathBuf>,
included_dirs: HashSet<PathBuf>,
repo_root: PathBuf,
}
impl GitignoreFilter {
pub fn new(path: &Path) -> Option<Self> {
let repo_root = Self::find_repo_root(path)?;
let mut included_files = HashSet::new();
let mut included_dirs = HashSet::new();
let walker = WalkBuilder::new(&repo_root)
.hidden(false) .git_ignore(true) .git_global(true) .git_exclude(true) .build();
for entry in walker.flatten() {
let entry_path = entry.path().to_path_buf();
if entry_path.is_file() {
included_files.insert(entry_path);
} else if entry_path.is_dir() {
included_dirs.insert(entry_path);
}
}
for file_path in &included_files.clone() {
let mut current = file_path.parent();
while let Some(dir) = current {
if !included_dirs.insert(dir.to_path_buf()) {
break; }
current = dir.parent();
}
}
Some(Self {
included_files,
included_dirs,
repo_root,
})
}
fn find_repo_root(path: &Path) -> Option<PathBuf> {
let mut current = if path.is_file() {
path.parent()?.to_path_buf()
} else {
path.to_path_buf()
};
current = current.canonicalize().ok()?;
loop {
if current.join(".git").exists() {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
pub fn is_included(&self, path: &Path) -> bool {
let path = match path.canonicalize() {
Ok(p) => p,
Err(_) => path.to_path_buf(),
};
if self.included_files.contains(&path) {
return true;
}
if path.is_dir() {
return self.included_dirs.contains(&path);
}
false
}
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
}
pub struct GitFilter {
#[allow(dead_code)]
repo: Repository,
tracked_files: HashSet<PathBuf>,
tracked_dirs: HashSet<PathBuf>,
repo_root: PathBuf,
}
impl GitFilter {
pub fn new(path: &Path) -> Option<Self> {
let repo = Repository::discover(path).ok()?;
let repo_root = repo.workdir()?.to_path_buf();
let tracked_files = Self::collect_tracked_files(&repo, &repo_root)?;
let mut tracked_dirs = HashSet::new();
for file_path in &tracked_files {
let mut current = file_path.parent();
while let Some(dir) = current {
if !tracked_dirs.insert(dir.to_path_buf()) {
break;
}
current = dir.parent();
}
}
Some(Self {
repo,
tracked_files,
tracked_dirs,
repo_root,
})
}
fn collect_tracked_files(repo: &Repository, repo_root: &Path) -> Option<HashSet<PathBuf>> {
let mut tracked = HashSet::new();
let index = repo.index().ok()?;
for entry in index.iter() {
let path_str = String::from_utf8_lossy(&entry.path);
let full_path = repo_root.join(path_str.as_ref());
tracked.insert(full_path);
}
let statuses = repo.statuses(None).ok()?;
for entry in statuses.iter() {
let status = entry.status();
if !status.contains(Status::WT_NEW) && !status.contains(Status::IGNORED) {
if let Some(path) = entry.path() {
let full_path = repo_root.join(path);
tracked.insert(full_path);
}
}
}
Some(tracked)
}
pub fn is_tracked(&self, path: &Path) -> bool {
let path = match path.canonicalize() {
Ok(p) => p,
Err(_) => path.to_path_buf(),
};
if self.tracked_files.contains(&path) {
return true;
}
if path.is_dir() {
return self.tracked_dirs.contains(&path);
}
false
}
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn create_test_repo() -> TempDir {
let dir = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir.path())
.output()
.unwrap();
dir
}
#[test]
fn test_tracked_file() {
let dir = create_test_repo();
let file_path = dir.path().join("tracked.rs");
fs::write(&file_path, "fn main() {}").unwrap();
Command::new("git")
.args(["add", "tracked.rs"])
.current_dir(dir.path())
.output()
.unwrap();
let filter = GitFilter::new(dir.path()).unwrap();
assert!(filter.is_tracked(&file_path));
}
#[test]
fn test_untracked_file() {
let dir = create_test_repo();
let tracked = dir.path().join("tracked.rs");
let untracked = dir.path().join("untracked.rs");
fs::write(&tracked, "fn main() {}").unwrap();
fs::write(&untracked, "fn other() {}").unwrap();
Command::new("git")
.args(["add", "tracked.rs"])
.current_dir(dir.path())
.output()
.unwrap();
let filter = GitFilter::new(dir.path()).unwrap();
assert!(filter.is_tracked(&tracked));
assert!(!filter.is_tracked(&untracked));
}
#[test]
fn test_gitignore_basic() {
let dir = create_test_repo();
fs::write(dir.path().join(".gitignore"), "*.log\ntarget/\n").unwrap();
fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
fs::write(dir.path().join("debug.log"), "log content").unwrap();
let filter = GitignoreFilter::new(dir.path()).unwrap();
assert!(filter.is_included(&dir.path().join("main.rs")));
assert!(!filter.is_included(&dir.path().join("debug.log")));
}
#[test]
fn test_gitignore_directory() {
let dir = create_test_repo();
fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::create_dir_all(dir.path().join("target")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
fs::write(dir.path().join("target/debug"), "binary").unwrap();
let filter = GitignoreFilter::new(dir.path()).unwrap();
assert!(filter.is_included(&dir.path().join("src")));
assert!(filter.is_included(&dir.path().join("src/main.rs")));
assert!(!filter.is_included(&dir.path().join("target")));
assert!(!filter.is_included(&dir.path().join("target/debug")));
}
#[test]
fn test_gitignore_nested() {
let dir = create_test_repo();
fs::write(dir.path().join(".gitignore"), "*.log\n").unwrap();
fs::create_dir_all(dir.path().join("subdir")).unwrap();
fs::write(dir.path().join("subdir/.gitignore"), "*.tmp\n").unwrap();
fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
fs::write(dir.path().join("subdir/code.rs"), "fn code() {}").unwrap();
fs::write(dir.path().join("subdir/cache.tmp"), "temp").unwrap();
fs::write(dir.path().join("root.log"), "log").unwrap();
fs::write(dir.path().join("subdir/nested.log"), "log").unwrap();
let filter = GitignoreFilter::new(dir.path()).unwrap();
assert!(filter.is_included(&dir.path().join("main.rs")));
assert!(filter.is_included(&dir.path().join("subdir/code.rs")));
assert!(!filter.is_included(&dir.path().join("subdir/cache.tmp")));
assert!(!filter.is_included(&dir.path().join("root.log")));
assert!(!filter.is_included(&dir.path().join("subdir/nested.log")));
}
#[test]
fn test_gitignore_negation() {
let dir = create_test_repo();
fs::write(dir.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
fs::write(dir.path().join("debug.log"), "debug").unwrap();
fs::write(dir.path().join("important.log"), "important").unwrap();
let filter = GitignoreFilter::new(dir.path()).unwrap();
assert!(!filter.is_included(&dir.path().join("debug.log")));
assert!(filter.is_included(&dir.path().join("important.log")));
}
}