use std::path::Path;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use crate::model::DiffFile;
pub fn filter_diff_files(repo_root: &Path, diff_files: Vec<DiffFile>) -> Vec<DiffFile> {
filter_diff_files_with_matcher(load_matcher(repo_root).as_ref(), diff_files)
}
#[must_use]
pub fn matcher_from_patterns(repo_root: &Path, patterns: &[String]) -> Option<Gitignore> {
if patterns.is_empty() {
return None;
}
let mut builder = GitignoreBuilder::new(repo_root);
let mut any_added = false;
for pat in patterns {
if builder.add_line(None, pat).is_ok() {
any_added = true;
}
}
if !any_added {
return None;
}
builder.build().ok()
}
pub fn filter_diff_files_with_matcher(
matcher: Option<&Gitignore>,
diff_files: Vec<DiffFile>,
) -> Vec<DiffFile> {
let Some(matcher) = matcher else {
return diff_files;
};
diff_files
.into_iter()
.filter(|file| {
let Some(path) = file.display_path() else {
return true;
};
!is_ignored(matcher, path)
})
.collect()
}
#[must_use]
pub fn load_matcher(repo_root: &Path) -> Option<Gitignore> {
let gitignore_file = repo_root.join(".gitignore");
let trvignore_file = repo_root.join(".trvignore");
if !gitignore_file.is_file() && !trvignore_file.is_file() {
return None;
}
let mut builder = GitignoreBuilder::new(repo_root);
if gitignore_file.is_file() {
let _ = builder.add(&gitignore_file);
}
if trvignore_file.is_file() {
let _ = builder.add(&trvignore_file);
}
builder.build().ok()
}
#[must_use]
pub fn is_ignored(matcher: &Gitignore, path: &Path) -> bool {
matcher.matched_path_or_any_parents(path, false).is_ignore()
|| matcher.matched_path_or_any_parents(path, true).is_ignore()
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
use super::*;
use crate::model::FileStatus;
fn make_diff_file(path: &str) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks: Vec::new(),
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
#[test]
fn keeps_all_files_when_trvignore_is_missing() {
let dir = tempdir().expect("failed to create temp dir");
let files = vec![
make_diff_file("src/main.rs"),
make_diff_file("target/debug/app"),
];
let filtered = filter_diff_files(dir.path(), files);
assert_eq!(filtered.len(), 2);
}
#[test]
fn filters_matching_files() {
let dir = tempdir().expect("failed to create temp dir");
let ignore_path = dir.path().join(".trvignore");
fs::write(&ignore_path, "target/\n*.lock\n").expect("failed to write .trvignore");
let files = vec![
make_diff_file("src/main.rs"),
make_diff_file("target/debug/app"),
make_diff_file("Cargo.lock"),
];
let filtered = filter_diff_files(dir.path(), files);
let kept_paths: Vec<String> = filtered
.iter()
.map(|f| f.display_path().unwrap().display().to_string())
.collect();
assert_eq!(kept_paths, vec!["src/main.rs"]);
}
#[test]
fn supports_unignore_rules() {
let dir = tempdir().expect("failed to create temp dir");
let ignore_path = dir.path().join(".trvignore");
fs::write(&ignore_path, "generated/\n!generated/keep.rs\n")
.expect("failed to write .trvignore");
let files = vec![
make_diff_file("generated/drop.rs"),
make_diff_file("generated/keep.rs"),
make_diff_file("src/main.rs"),
];
let filtered = filter_diff_files(dir.path(), files);
let kept_paths: Vec<String> = filtered
.iter()
.map(|f| f.display_path().unwrap().display().to_string())
.collect();
assert_eq!(kept_paths, vec!["generated/keep.rs", "src/main.rs"]);
}
#[test]
fn respects_gitignore() {
let dir = tempdir().expect("failed to create temp dir");
let gitignore = dir.path().join(".gitignore");
fs::write(&gitignore, "target/\n*.log\n").expect("failed to write .gitignore");
let files = vec![
make_diff_file("src/main.rs"),
make_diff_file("target/debug/app"),
make_diff_file("build.log"),
];
let filtered = filter_diff_files(dir.path(), files);
let kept: Vec<String> = filtered
.iter()
.map(|f| f.display_path().unwrap().display().to_string())
.collect();
assert_eq!(kept, vec!["src/main.rs"]);
}
#[test]
fn trvignore_overrides_gitignore() {
let dir = tempdir().expect("failed to create temp dir");
let gitignore = dir.path().join(".gitignore");
fs::write(&gitignore, "*.lock\n").expect("failed to write .gitignore");
let trvignore = dir.path().join(".trvignore");
fs::write(&trvignore, "!Cargo.lock\n").expect("failed to write .trvignore");
let files = vec![
make_diff_file("Cargo.lock"),
make_diff_file("yarn.lock"),
make_diff_file("src/lib.rs"),
];
let filtered = filter_diff_files(dir.path(), files);
let kept: Vec<String> = filtered
.iter()
.map(|f| f.display_path().unwrap().display().to_string())
.collect();
assert_eq!(kept, vec!["Cargo.lock", "src/lib.rs"]);
}
#[test]
fn gitignore_alone_filters_without_trvignore() {
let dir = tempdir().expect("failed to create temp dir");
let gitignore = dir.path().join(".gitignore");
fs::write(&gitignore, "dist/\n").expect("failed to write .gitignore");
let files = vec![
make_diff_file("src/index.ts"),
make_diff_file("dist/bundle.js"),
];
let filtered = filter_diff_files(dir.path(), files);
let kept: Vec<String> = filtered
.iter()
.map(|f| f.display_path().unwrap().display().to_string())
.collect();
assert_eq!(kept, vec!["src/index.ts"]);
}
#[test]
fn matcher_from_patterns_hides_matches() {
let dir = tempdir().expect("failed to create temp dir");
let matcher = matcher_from_patterns(
dir.path(),
&["tests/**".to_string(), "*_test.*".to_string()],
)
.expect("matcher should build");
let files = vec![
make_diff_file("src/main.rs"),
make_diff_file("tests/integration.rs"),
make_diff_file("src/foo_test.rs"),
make_diff_file("src/bar.rs"),
];
let filtered = filter_diff_files_with_matcher(Some(&matcher), files);
let kept: Vec<String> = filtered
.iter()
.map(|f| f.display_path().unwrap().display().to_string())
.collect();
assert_eq!(kept, vec!["src/main.rs", "src/bar.rs"]);
}
#[test]
fn matcher_from_patterns_returns_none_for_empty_input() {
let dir = tempdir().unwrap();
assert!(matcher_from_patterns(dir.path(), &[]).is_none());
}
#[test]
fn filter_diff_files_with_matcher_passes_through_when_none() {
let files = vec![
make_diff_file("src/main.rs"),
make_diff_file("tests/integration.rs"),
];
let filtered = filter_diff_files_with_matcher(None, files.clone());
assert_eq!(filtered.len(), files.len());
}
#[test]
fn handles_deleted_file_paths() {
let dir = tempdir().expect("failed to create temp dir");
let ignore_path = dir.path().join(".trvignore");
fs::write(&ignore_path, "generated/\n").expect("failed to write .trvignore");
let deleted = DiffFile {
old_path: Some(PathBuf::from("generated/old.txt")),
new_path: None,
status: FileStatus::Deleted,
hunks: Vec::new(),
is_binary: false,
is_too_large: false,
is_commit_message: false,
};
let kept = make_diff_file("src/lib.rs");
let filtered = filter_diff_files(dir.path(), vec![deleted, kept]);
let kept_paths: Vec<String> = filtered
.iter()
.map(|f| f.display_path().unwrap().display().to_string())
.collect();
assert_eq!(kept_paths, vec!["src/lib.rs"]);
}
}