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::path::{Path, PathBuf};
15use std::sync::Arc;
16use tokio::fs;
17
18/// Represents a .vtcodegitignore file with pattern matching capabilities
19#[derive(Debug, Clone)]
20pub struct VTCodeGitignore {
21    /// Root directory where .vtcodegitignore was found
22    root_dir: PathBuf,
23    /// Compiled gitignore matcher
24    matcher: Gitignore,
25    /// Whether the .vtcodegitignore file exists and was loaded
26    loaded: bool,
27}
28
29impl VTCodeGitignore {
30    /// Create a new VTCodeGitignore instance by looking for .vtcodegitignore in the current directory
31    pub async fn new() -> Result<Self> {
32        let current_dir = std::env::current_dir().map_err(|e| anyhow!("Failed to get current directory: {e}"))?;
33
34        Self::from_directory(&current_dir).await
35    }
36
37    /// Create a VTCodeGitignore instance from a specific directory
38    pub async fn from_directory(root_dir: &Path) -> Result<Self> {
39        let gitignore_path = root_dir.join(".vtcodegitignore");
40
41        let mut loaded = false;
42        let mut builder = GitignoreBuilder::new(root_dir);
43
44        if gitignore_path.exists() {
45            match Self::load_patterns(&gitignore_path, &mut builder).await {
46                Ok(()) => {
47                    loaded = true;
48                }
49                Err(e) => {
50                    // Log warning but don't fail - just treat as no patterns
51                    tracing::warn!("Failed to load .vtcodegitignore: {}", e);
52                }
53            }
54        }
55
56        let matcher = builder.build().unwrap_or_else(|_| {
57            // Fallback to empty matcher on build error
58            Gitignore::empty()
59        });
60
61        Ok(Self { root_dir: root_dir.to_path_buf(), matcher, loaded })
62    }
63
64    /// Load patterns from the .vtcodegitignore file into the builder
65    async fn load_patterns(file_path: &Path, builder: &mut GitignoreBuilder) -> Result<()> {
66        let content = fs::read_to_string(file_path)
67            .await
68            .map_err(|e| anyhow!("Failed to read .vtcodegitignore: {e}"))?;
69
70        for (line_num, line) in content.lines().enumerate() {
71            let line = line.trim();
72
73            // Skip empty lines and comments
74            if line.is_empty() || line.starts_with('#') {
75                continue;
76            }
77
78            builder
79                .add_line(None, line)
80                .map_err(|e| anyhow!("Invalid pattern on line {}: '{}': {}", line_num + 1, line, e))?;
81        }
82
83        Ok(())
84    }
85
86    /// Check if a file path should be excluded based on the .vtcodegitignore patterns
87    pub fn should_exclude(&self, file_path: &Path) -> bool {
88        if !self.loaded {
89            return false;
90        }
91
92        // Convert to relative path from the root directory
93        let relative_path = match file_path.strip_prefix(&self.root_dir) {
94            Ok(rel) => rel,
95            Err(_) => file_path,
96        };
97
98        self.matcher
99            .matched_path_or_any_parents(relative_path, file_path.is_dir())
100            .is_ignore()
101    }
102
103    /// Filter a list of file paths based on .vtcodegitignore patterns
104    pub fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
105        if !self.loaded {
106            return paths;
107        }
108
109        paths.into_iter().filter(|path| !self.should_exclude(path)).collect()
110    }
111
112    /// Check if the .vtcodegitignore file was loaded successfully
113    pub fn is_loaded(&self) -> bool {
114        self.loaded
115    }
116
117    /// Get the number of patterns loaded
118    pub fn pattern_count(&self) -> usize {
119        self.matcher.num_ignores() as usize
120    }
121
122    /// Get the root directory
123    pub fn root_dir(&self) -> &Path {
124        &self.root_dir
125    }
126}
127
128impl Default for VTCodeGitignore {
129    fn default() -> Self {
130        let root_dir = PathBuf::new();
131        let matcher = Gitignore::empty();
132        Self { root_dir, matcher, loaded: false }
133    }
134}
135
136/// Global .vtcodegitignore instance for easy access
137static VTCODE_GITIGNORE: once_cell::sync::Lazy<tokio::sync::RwLock<Arc<VTCodeGitignore>>> =
138    once_cell::sync::Lazy::new(|| tokio::sync::RwLock::new(Arc::new(VTCodeGitignore::default())));
139
140/// Initialize the global .vtcodegitignore instance
141pub async fn initialize_vtcode_gitignore() -> Result<()> {
142    let gitignore = VTCodeGitignore::new().await?;
143    let mut global_gitignore = VTCODE_GITIGNORE.write().await;
144    *global_gitignore = Arc::new(gitignore);
145    Ok(())
146}
147
148/// Snapshot the global .vtcodegitignore instance.
149pub async fn snapshot_global_vtcode_gitignore() -> Arc<VTCodeGitignore> {
150    VTCODE_GITIGNORE.read().await.clone()
151}
152
153/// Check if a file should be excluded by the global .vtcodegitignore
154pub async fn should_exclude_file(file_path: &Path) -> bool {
155    let gitignore = snapshot_global_vtcode_gitignore().await;
156    gitignore.should_exclude(file_path)
157}
158
159/// Filter paths using the global .vtcodegitignore
160pub async fn filter_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
161    let gitignore = snapshot_global_vtcode_gitignore().await;
162    gitignore.filter_paths(paths)
163}
164
165/// Reload the global .vtcodegitignore from disk
166pub async fn reload_vtcode_gitignore() -> Result<()> {
167    initialize_vtcode_gitignore().await
168}