Skip to main content

omnifuse_git/
filter.rs

1//! File filtering via `.gitignore`.
2
3use std::path::Path;
4
5use ignore::gitignore::{Gitignore, GitignoreBuilder};
6use tracing::debug;
7
8/// Filter based on `.gitignore`.
9#[derive(Debug)]
10pub struct GitignoreFilter {
11  /// Gitignore parser.
12  gitignore: Gitignore
13}
14
15impl GitignoreFilter {
16  /// Create a filter for a directory.
17  ///
18  /// Looks for `.gitignore` in the specified directory.
19  #[must_use]
20  pub fn new(repo_path: &Path) -> Self {
21    let gitignore_path = repo_path.join(".gitignore");
22    let mut builder = GitignoreBuilder::new(repo_path);
23
24    if gitignore_path.exists()
25      && let Some(err) = builder.add(&gitignore_path)
26    {
27      debug!(error = %err, "error parsing .gitignore");
28    }
29
30    let gitignore = builder.build().unwrap_or_else(|_| {
31      // Empty gitignore if parsing failed
32      GitignoreBuilder::new(repo_path)
33        .build()
34        .unwrap_or_else(|_| Gitignore::empty())
35    });
36
37    Self { gitignore }
38  }
39
40  /// Check whether a file is ignored.
41  #[must_use]
42  pub fn is_ignored(&self, path: &Path) -> bool {
43    let is_dir = path.is_dir();
44    self.gitignore.matched_path_or_any_parents(path, is_dir).is_ignore()
45  }
46}
47
48#[cfg(test)]
49#[allow(clippy::expect_used)]
50mod tests {
51  use std::path::PathBuf;
52
53  use super::*;
54
55  #[test]
56  fn test_empty_filter() {
57    let filter = GitignoreFilter::new(Path::new("/nonexistent"));
58    // Without .gitignore nothing is ignored
59    assert!(!filter.is_ignored(&PathBuf::from("/nonexistent/file.txt")));
60  }
61
62  #[test]
63  fn test_gitignore_pattern() {
64    let tmp = tempfile::tempdir().expect("tempdir");
65    let path = tmp.path();
66    std::fs::write(path.join(".gitignore"), "*.log\n").expect("write gitignore");
67
68    let filter = GitignoreFilter::new(path);
69    assert!(
70      filter.is_ignored(&path.join("test.log")),
71      "*.log should ignore test.log"
72    );
73  }
74
75  #[test]
76  fn test_gitignore_allows_unmatched() {
77    let tmp = tempfile::tempdir().expect("tempdir");
78    let path = tmp.path();
79    std::fs::write(path.join(".gitignore"), "*.log\n").expect("write gitignore");
80
81    let filter = GitignoreFilter::new(path);
82    assert!(
83      !filter.is_ignored(&path.join("file.txt")),
84      "file.txt should not be ignored"
85    );
86  }
87
88  #[test]
89  fn test_gitignore_dir_pattern() {
90    let tmp = tempfile::tempdir().expect("tempdir");
91    let path = tmp.path();
92    std::fs::write(path.join(".gitignore"), "build/\n").expect("write gitignore");
93
94    // Create build/ directory so is_dir() works
95    std::fs::create_dir(path.join("build")).expect("mkdir build");
96    std::fs::write(path.join("build/output.js"), "data").expect("write file");
97
98    let filter = GitignoreFilter::new(path);
99    assert!(filter.is_ignored(&path.join("build")), "build/ should be ignored");
100  }
101
102  /// macOS system files in gitignore.
103  #[test]
104  fn test_macos_dotfiles_ignored() {
105    let tmp = tempfile::tempdir().expect("tempdir");
106    let path = tmp.path();
107    std::fs::write(path.join(".gitignore"), ".DS_Store\n._*\n").expect("write");
108
109    let filter = GitignoreFilter::new(path);
110    assert!(
111      filter.is_ignored(&path.join(".DS_Store")),
112      ".DS_Store should be ignored"
113    );
114    assert!(
115      filter.is_ignored(&path.join("._test.txt")),
116      "._test.txt should be ignored"
117    );
118    assert!(
119      !filter.is_ignored(&path.join("normal.txt")),
120      "normal.txt should not be ignored"
121    );
122  }
123
124  /// Editor temporary files in gitignore.
125  #[test]
126  fn test_editor_temp_files_ignored() {
127    let tmp = tempfile::tempdir().expect("tempdir");
128    let path = tmp.path();
129    std::fs::write(path.join(".gitignore"), "*.swp\n*~\n").expect("write");
130
131    let filter = GitignoreFilter::new(path);
132    assert!(filter.is_ignored(&path.join(".file.swp")), ".swp should be ignored");
133    assert!(filter.is_ignored(&path.join("file.txt~")), "backup~ should be ignored");
134    assert!(
135      !filter.is_ignored(&path.join("file.txt")),
136      "file.txt should not be ignored"
137    );
138  }
139
140  /// Multiple patterns in gitignore.
141  #[test]
142  fn test_multiple_gitignore_patterns() {
143    let tmp = tempfile::tempdir().expect("tempdir");
144    let path = tmp.path();
145    std::fs::write(path.join(".gitignore"), "*.log\n*.tmp\ntarget/\n").expect("write");
146
147    // Create target/ so is_dir() works
148    std::fs::create_dir(path.join("target")).expect("mkdir");
149
150    let filter = GitignoreFilter::new(path);
151    assert!(
152      filter.is_ignored(&path.join("debug.log")),
153      "debug.log should be ignored"
154    );
155    assert!(filter.is_ignored(&path.join("temp.tmp")), "temp.tmp should be ignored");
156    assert!(filter.is_ignored(&path.join("target")), "target/ should be ignored");
157    assert!(
158      !filter.is_ignored(&path.join("src/main.rs")),
159      "main.rs should not be ignored"
160    );
161  }
162
163  /// .git (directory) — hidden (filtered), .gitignore — NOT hidden.
164  /// Verify that the .git/ pattern filters the directory itself,
165  /// but .gitignore (a file not matching the pattern) is not filtered.
166  #[test]
167  fn test_is_hidden_path() {
168    let tmp = tempfile::tempdir().expect("tempdir");
169    let path = tmp.path();
170    std::fs::write(path.join(".gitignore"), ".git/\n").expect("write gitignore");
171
172    // Create .git/ so is_dir() works
173    std::fs::create_dir(path.join(".git")).expect("mkdir .git");
174
175    let filter = GitignoreFilter::new(path);
176    assert!(
177      filter.is_ignored(&path.join(".git")),
178      ".git directory should be hidden (filtered)"
179    );
180    assert!(
181      !filter.is_ignored(&path.join(".gitignore")),
182      ".gitignore should NOT be hidden (it is not .git/)"
183    );
184    assert!(
185      !filter.is_ignored(&path.join(".gitkeep")),
186      ".gitkeep should NOT be hidden (it is not .git/)"
187    );
188  }
189
190  /// Combined gitignore: *.log + *.tmp + .DS_Store.
191  /// More thorough test with multiple patterns and verification of non-ignored files.
192  #[test]
193  fn test_gitignore_combined_patterns() {
194    let tmp = tempfile::tempdir().expect("tempdir");
195    let path = tmp.path();
196    std::fs::write(path.join(".gitignore"), "*.log\n*.tmp\n.DS_Store\n").expect("write");
197
198    let filter = GitignoreFilter::new(path);
199
200    // Should be ignored
201    assert!(
202      filter.is_ignored(&path.join("test.log")),
203      "test.log should be ignored (pattern *.log)"
204    );
205    assert!(
206      filter.is_ignored(&path.join("test.tmp")),
207      "test.tmp should be ignored (pattern *.tmp)"
208    );
209    assert!(
210      filter.is_ignored(&path.join(".DS_Store")),
211      ".DS_Store should be ignored (exact match)"
212    );
213    assert!(
214      filter.is_ignored(&path.join("subdir/deep.log")),
215      "subdir/deep.log should be ignored (*.log recursive)"
216    );
217    assert!(
218      filter.is_ignored(&path.join("cache/session.tmp")),
219      "cache/session.tmp should be ignored (*.tmp recursive)"
220    );
221
222    // Should NOT be ignored
223    assert!(
224      !filter.is_ignored(&path.join("README.md")),
225      "README.md should NOT be ignored"
226    );
227    assert!(
228      !filter.is_ignored(&path.join("src/main.rs")),
229      "src/main.rs should NOT be ignored"
230    );
231    assert!(
232      !filter.is_ignored(&path.join("test.txt")),
233      "test.txt should NOT be ignored"
234    );
235    assert!(
236      !filter.is_ignored(&path.join("logger.rs")),
237      "logger.rs should NOT be ignored (contains 'log' but not as extension)"
238    );
239  }
240}