Skip to main content

vtcode_commons/
vtcodegitignore.rs

1#![expect(
2    clippy::cast_possible_truncation,
3    unused_results,
4    reason = "Ignore-pattern counts use the platform's documented compact representation and builder calls are side effects."
5)]
6
7//! .vtcodegitignore file pattern matching utilities
8//!
9//! Uses the `ignore` crate's gitignore parser for correct, battle-tested
10//! pattern matching instead of hand-rolled glob conversion.
11
12use anyhow::{Result, anyhow};
13use ignore::gitignore::{Gitignore, GitignoreBuilder};
14use std::fs::FileType;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use tokio::fs;
18
19/// Represents a .vtcodegitignore file with pattern matching capabilities
20#[derive(Debug, Clone)]
21pub struct VTCodeGitignore {
22    /// Root directory where .vtcodegitignore was found
23    root_dir: PathBuf,
24    /// The same root resolved on disk, when it exists.
25    ///
26    /// Walkers report resolved paths, so a workspace reached through an alias
27    /// (`/var` vs macOS's `/private/var`) yields entries that do not start with
28    /// `root_dir`. Keeping both spellings lets matching stay lexical instead of
29    /// canonicalizing every entry.
30    canonical_root: Option<PathBuf>,
31    /// Compiled gitignore matcher
32    matcher: Gitignore,
33    /// Whether the .vtcodegitignore file exists and was loaded
34    loaded: bool,
35}
36
37impl VTCodeGitignore {
38    /// Create a new VTCodeGitignore instance by looking for .vtcodegitignore in the current directory
39    pub async fn new() -> Result<Self> {
40        let current_dir = std::env::current_dir().map_err(|e| anyhow!("Failed to get current directory: {e}"))?;
41
42        Self::from_directory(&current_dir).await
43    }
44
45    /// Create a VTCodeGitignore instance from a specific directory
46    pub async fn from_directory(root_dir: &Path) -> Result<Self> {
47        let gitignore_path = root_dir.join(".vtcodegitignore");
48
49        let mut loaded = false;
50        let mut builder = GitignoreBuilder::new(root_dir);
51
52        if gitignore_path.exists() {
53            match Self::load_patterns(&gitignore_path, &mut builder).await {
54                Ok(()) => {
55                    loaded = true;
56                }
57                Err(e) => {
58                    // Log warning but don't fail - just treat as no patterns
59                    tracing::warn!("Failed to load .vtcodegitignore: {}", e);
60                }
61            }
62        }
63
64        let matcher = builder.build().unwrap_or_else(|_| {
65            // Fallback to empty matcher on build error
66            Gitignore::empty()
67        });
68
69        // Resolved once here so the per-entry match path never canonicalizes.
70        let canonical_root = crate::paths::canonicalize(root_dir).ok();
71
72        Ok(Self {
73            root_dir: root_dir.to_path_buf(),
74            canonical_root,
75            matcher,
76            loaded,
77        })
78    }
79
80    /// Load patterns from the .vtcodegitignore file into the builder
81    async fn load_patterns(file_path: &Path, builder: &mut GitignoreBuilder) -> Result<()> {
82        let content = fs::read_to_string(file_path)
83            .await
84            .map_err(|e| anyhow!("Failed to read .vtcodegitignore: {e}"))?;
85
86        for (line_num, line) in content.lines().enumerate() {
87            let line = line.trim();
88
89            // Skip empty lines and comments
90            if line.is_empty() || line.starts_with('#') {
91                continue;
92            }
93
94            builder
95                .add_line(None, line)
96                .map_err(|e| anyhow!("Invalid pattern on line {}: '{}': {}", line_num + 1, line, e))?;
97        }
98
99        Ok(())
100    }
101
102    /// Check if a file path should be excluded based on the .vtcodegitignore patterns
103    pub fn should_exclude(&self, file_path: &Path) -> bool {
104        self.should_exclude_with_is_dir(file_path, file_path.is_dir())
105    }
106
107    /// Same as [`Self::should_exclude`] using a file type the caller already has.
108    ///
109    /// Traversal code holds each entry's `FileType` from `readdir`, so calling
110    /// `is_dir()` again costs one `stat` per entry for a fact already in hand.
111    ///
112    /// A symlink's own type says nothing about its target, and this matcher has
113    /// always followed the link (`Path::is_dir`), so symlinks and unknown types
114    /// take the `stat` path rather than guessing.
115    pub fn should_exclude_with_file_type(&self, file_path: &Path, file_type: Option<FileType>) -> bool {
116        match file_type {
117            Some(file_type) if !file_type.is_symlink() => {
118                self.should_exclude_with_is_dir(file_path, file_type.is_dir())
119            }
120            _ => self.should_exclude(file_path),
121        }
122    }
123
124    /// Same as [`Self::should_exclude`] with a caller-supplied directory flag.
125    ///
126    /// The caller must have resolved the flag from the target, not from a
127    /// symlink's own type; prefer [`Self::should_exclude_with_file_type`],
128    /// which applies that rule.
129    pub fn should_exclude_with_is_dir(&self, file_path: &Path, is_dir: bool) -> bool {
130        if !self.loaded {
131            return false;
132        }
133
134        // Patterns only govern paths below this file's root, and the underlying
135        // matcher *asserts* that its input is rootless. Handing it a path that
136        // could not be relativized — outside the root, or spelled through a
137        // different alias of the same directory (`/var` vs macOS's
138        // `/private/var`) — aborts the process instead of excluding anything.
139        //
140        // Try both spellings of the root. Walkers report resolved paths, so the
141        // canonical form is what usually matches; the user-facing form covers
142        // paths that arrive as typed. Both are compared lexically, so this stays
143        // syscall-free on a per-entry path.
144        if let Some(relative) = self.relative_to_root(file_path) {
145            return self.matcher.matched_path_or_any_parents(relative, is_dir).is_ignore();
146        }
147
148        // Still unrelated lexically: the entry may be reached through yet
149        // another alias of the root. Resolving is a syscall, so it happens only
150        // after both cheap spellings failed, never per entry in a walk.
151        let Some(canonical_root) = self.canonical_root.as_ref() else {
152            return false;
153        };
154        let Ok(canonical_path) = crate::paths::canonicalize(file_path) else {
155            return false;
156        };
157        let Ok(relative) = canonical_path.strip_prefix(canonical_root) else {
158            return false;
159        };
160
161        self.matcher.matched_path_or_any_parents(relative, is_dir).is_ignore()
162    }
163
164    /// Relativize `file_path` against either stored spelling of the root.
165    ///
166    /// The result borrows `file_path` (both `strip_prefix` cases slice it), so
167    /// its lifetime is independent of `self`.
168    fn relative_to_root<'a>(&self, file_path: &'a Path) -> Option<&'a Path> {
169        file_path
170            .strip_prefix(&self.root_dir)
171            .ok()
172            .or_else(|| self.canonical_root.as_ref().and_then(|root| file_path.strip_prefix(root).ok()))
173    }
174
175    /// Filter a list of file paths based on .vtcodegitignore patterns
176    pub fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
177        if !self.loaded {
178            return paths;
179        }
180
181        paths.into_iter().filter(|path| !self.should_exclude(path)).collect()
182    }
183
184    /// Check if the .vtcodegitignore file was loaded successfully
185    pub fn is_loaded(&self) -> bool {
186        self.loaded
187    }
188
189    /// Get the number of patterns loaded
190    pub fn pattern_count(&self) -> usize {
191        self.matcher.num_ignores() as usize
192    }
193
194    /// Get the root directory
195    pub fn root_dir(&self) -> &Path {
196        &self.root_dir
197    }
198}
199
200impl Default for VTCodeGitignore {
201    fn default() -> Self {
202        let root_dir = PathBuf::new();
203        let matcher = Gitignore::empty();
204        Self {
205            root_dir,
206            canonical_root: None,
207            matcher,
208            loaded: false,
209        }
210    }
211}
212
213/// Global .vtcodegitignore instance for easy access.
214///
215/// Read-mostly: `should_exclude_file`/`filter_paths` are called per file while
216/// `reload_vtcode_gitignore` swaps the whole matcher. A `tokio::sync::RwLock`
217/// would put atomic contention and an async lock acquisition on the read path;
218/// `ArcSwap` keeps reads lock-free (the article's "beware RWLock contention"
219/// guidance) while preserving atomic whole-value replacement.
220static VTCODE_GITIGNORE: once_cell::sync::Lazy<arc_swap::ArcSwap<VTCodeGitignore>> =
221    once_cell::sync::Lazy::new(|| arc_swap::ArcSwap::from_pointee(VTCodeGitignore::default()));
222
223/// Initialize the global .vtcodegitignore instance
224pub async fn initialize_vtcode_gitignore() -> Result<()> {
225    let gitignore = VTCodeGitignore::new().await?;
226    VTCODE_GITIGNORE.store(Arc::new(gitignore));
227    Ok(())
228}
229
230/// Snapshot the global .vtcodegitignore instance.
231pub async fn snapshot_global_vtcode_gitignore() -> Arc<VTCodeGitignore> {
232    VTCODE_GITIGNORE.load_full()
233}
234
235/// Check if a file should be excluded by the global .vtcodegitignore
236pub async fn should_exclude_file(file_path: &Path) -> bool {
237    let gitignore = snapshot_global_vtcode_gitignore().await;
238    gitignore.should_exclude(file_path)
239}
240
241/// Check if a path should be excluded, reusing a known directory flag.
242///
243/// The caller must supply a target-resolved flag; prefer
244/// [`should_exclude_file_with_file_type`] in traversal loops.
245pub async fn should_exclude_file_with_is_dir(file_path: &Path, is_dir: bool) -> bool {
246    let gitignore = snapshot_global_vtcode_gitignore().await;
247    gitignore.should_exclude_with_is_dir(file_path, is_dir)
248}
249
250/// Check if a path should be excluded, reusing a `FileType` the walker returned.
251///
252/// This is the traversal-loop form of [`should_exclude_file`]: it skips the
253/// `stat` that learning `is_dir` would otherwise cost, except for symlinks whose
254/// target decides the answer.
255pub async fn should_exclude_file_with_file_type(file_path: &Path, file_type: Option<FileType>) -> bool {
256    let gitignore = snapshot_global_vtcode_gitignore().await;
257    gitignore.should_exclude_with_file_type(file_path, file_type)
258}
259
260/// Filter paths using the global .vtcodegitignore
261pub async fn filter_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
262    let gitignore = snapshot_global_vtcode_gitignore().await;
263    gitignore.filter_paths(paths)
264}
265
266/// Reload the global .vtcodegitignore from disk
267pub async fn reload_vtcode_gitignore() -> Result<()> {
268    initialize_vtcode_gitignore().await
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// Build a matcher over a temp root with `patterns` written to disk.
276    async fn matcher_for(root: &Path, patterns: &str) -> VTCodeGitignore {
277        std::fs::write(root.join(".vtcodegitignore"), patterns).expect("write ignore file");
278        VTCodeGitignore::from_directory(root).await.expect("build matcher")
279    }
280
281    #[tokio::test]
282    async fn absolute_path_outside_the_ignore_root_is_not_excluded() {
283        let temp = tempfile::tempdir().unwrap();
284        let root = temp.path().join("workspace");
285        std::fs::create_dir_all(&root).unwrap();
286        let gitignore = matcher_for(&root, "target/\n").await;
287
288        let outside = temp.path().join("elsewhere").join("file.rs");
289        std::fs::create_dir_all(outside.parent().unwrap()).unwrap();
290        std::fs::write(&outside, "x").unwrap();
291
292        // `strip_prefix` cannot relativize this path, and the underlying matcher
293        // asserts that whatever it receives is rootless. A path outside the
294        // ignore file's scope simply is not covered by its patterns.
295        assert!(!gitignore.should_exclude(&outside));
296        assert!(!gitignore.should_exclude_with_is_dir(&outside, false));
297    }
298
299    #[cfg(unix)]
300    #[tokio::test]
301    async fn entries_reached_through_a_root_alias_still_match_patterns() {
302        use std::os::unix::fs::symlink;
303
304        let temp = tempfile::tempdir().unwrap();
305        let real_root = temp.path().join("workspace");
306        std::fs::create_dir_all(real_root.join("skipped")).unwrap();
307        let gitignore = matcher_for(&real_root, "skipped/\n").await;
308
309        // An alias of the same directory: the matcher was built from one
310        // spelling, but walkers report the other, so matching must survive it.
311        let alias_root = temp.path().join("workspace-alias");
312        symlink(&real_root, &alias_root).unwrap();
313        let alias_child = alias_root.join("skipped");
314
315        assert!(
316            gitignore.should_exclude_with_is_dir(&alias_child, true),
317            "a directory under an aliased root must still match the pattern"
318        );
319    }
320
321    #[tokio::test]
322    async fn known_file_type_matches_the_stat_based_answer() {
323        let temp = tempfile::tempdir().unwrap();
324        let root = temp.path();
325        std::fs::create_dir(root.join("skipped")).unwrap();
326        std::fs::write(root.join("kept.rs"), "x").unwrap();
327        let gitignore = matcher_for(root, "skipped/\n").await;
328
329        let dir_entry = std::fs::read_dir(root)
330            .unwrap()
331            .flatten()
332            .find(|e| e.file_name() == "skipped")
333            .unwrap();
334        let dir_type = dir_entry.file_type().unwrap();
335        let dir_path = dir_entry.path();
336        assert!(dir_type.is_dir());
337
338        // Directory ignore patterns must fire from the supplied type alone, and
339        // agree with the stat-resolving form.
340        assert_eq!(
341            gitignore.should_exclude_with_file_type(&dir_path, Some(dir_type)),
342            gitignore.should_exclude(&dir_path)
343        );
344        assert!(gitignore.should_exclude_with_file_type(&dir_path, Some(dir_type)));
345
346        let file_type = std::fs::symlink_metadata(root.join("kept.rs")).unwrap().file_type();
347        assert!(!gitignore.should_exclude_with_file_type(&root.join("kept.rs"), Some(file_type)));
348    }
349
350    #[test]
351    fn unknown_file_type_falls_back_to_the_filesystem() {
352        let temp = tempfile::tempdir().unwrap();
353        let root = temp.path();
354        let gitignore = VTCodeGitignore::default();
355        // `loaded == false` short-circuits, so nothing is excluded either way.
356        assert!(!gitignore.should_exclude_with_file_type(&root.join("anything"), None));
357    }
358
359    #[cfg(unix)]
360    #[tokio::test]
361    async fn symlink_entries_defer_to_the_target_type() {
362        use std::os::unix::fs::symlink;
363
364        let temp = tempfile::tempdir().unwrap();
365        let root = temp.path();
366        std::fs::create_dir(root.join("real-dir")).unwrap();
367        symlink(root.join("real-dir"), root.join("link-dir")).unwrap();
368        let gitignore = matcher_for(root, "link-dir/\n").await;
369
370        let link = root.join("link-dir");
371        let link_type = std::fs::symlink_metadata(&link).unwrap().file_type();
372        assert!(link_type.is_symlink());
373
374        // The link's own type is not a directory, but its target is; the
375        // resolution must match what `Path::is_dir` used to decide.
376        assert_eq!(gitignore.should_exclude_with_file_type(&link, Some(link_type)), gitignore.should_exclude(&link));
377    }
378}