Skip to main content

vtcode_commons/
vtcodegitignore.rs

1//! .vtcodegitignore file pattern matching utilities
2
3use anyhow::{Result, anyhow};
4use glob::Pattern;
5use std::path::{Path, PathBuf};
6use tokio::fs;
7
8/// Represents a .vtcodegitignore file with pattern matching capabilities
9#[derive(Debug, Clone)]
10pub struct VTCodeGitignore {
11    /// Root directory where .vtcodegitignore was found
12    root_dir: PathBuf,
13    /// Compiled glob patterns for matching
14    patterns: Vec<CompiledPattern>,
15    /// Whether the .vtcodegitignore file exists and was loaded
16    loaded: bool,
17}
18
19/// A compiled pattern with its original string and compiled glob
20#[derive(Debug, Clone)]
21struct CompiledPattern {
22    /// Original pattern string from the file
23    original: String,
24    /// Compiled glob pattern
25    pattern: Pattern,
26    /// Whether this is a negation pattern (starts with !)
27    negated: bool,
28}
29
30impl VTCodeGitignore {
31    /// Create a new VTCodeGitignore instance by looking for .vtcodegitignore in the current directory
32    pub async fn new() -> Result<Self> {
33        let current_dir = std::env::current_dir()
34            .map_err(|e| anyhow!("Failed to get current directory: {}", e))?;
35
36        Self::from_directory(&current_dir).await
37    }
38
39    /// Create a VTCodeGitignore instance from a specific directory
40    pub async fn from_directory(root_dir: &Path) -> Result<Self> {
41        let gitignore_path = root_dir.join(".vtcodegitignore");
42
43        let mut patterns = Vec::new();
44        let mut loaded = false;
45
46        if gitignore_path.exists() {
47            match Self::load_patterns(&gitignore_path).await {
48                Ok(loaded_patterns) => {
49                    patterns = loaded_patterns;
50                    loaded = true;
51                }
52                Err(e) => {
53                    // Log warning but don't fail - just treat as no patterns
54                    tracing::warn!("Failed to load .vtcodegitignore: {}", e);
55                }
56            }
57        }
58
59        Ok(Self {
60            root_dir: root_dir.to_path_buf(),
61            patterns,
62            loaded,
63        })
64    }
65
66    /// Load patterns from the .vtcodegitignore file
67    async fn load_patterns(file_path: &Path) -> Result<Vec<CompiledPattern>> {
68        let content = fs::read_to_string(file_path)
69            .await
70            .map_err(|e| anyhow!("Failed to read .vtcodegitignore: {}", e))?;
71
72        let mut patterns = Vec::new();
73
74        for (line_num, line) in content.lines().enumerate() {
75            let line = line.trim();
76
77            // Skip empty lines and comments
78            if line.is_empty() || line.starts_with('#') {
79                continue;
80            }
81
82            // Parse the pattern
83            let (pattern_str, negated) = if let Some(stripped) = line.strip_prefix('!') {
84                (stripped.to_string(), true)
85            } else {
86                (line.to_string(), false)
87            };
88
89            // Convert gitignore patterns to glob patterns
90            let glob_pattern = Self::convert_gitignore_to_glob(&pattern_str);
91
92            match Pattern::new(&glob_pattern) {
93                Ok(pattern) => {
94                    patterns.push(CompiledPattern {
95                        original: pattern_str,
96                        pattern,
97                        negated,
98                    });
99                }
100                Err(e) => {
101                    return Err(anyhow!(
102                        "Invalid pattern on line {}: '{}': {}",
103                        line_num + 1,
104                        pattern_str,
105                        e
106                    ));
107                }
108            }
109        }
110
111        Ok(patterns)
112    }
113
114    /// Convert gitignore pattern syntax to glob pattern syntax
115    fn convert_gitignore_to_glob(pattern: &str) -> String {
116        let mut result = pattern.to_string();
117
118        // Handle directory-only patterns (ending with /)
119        if result.ends_with('/') {
120            result = format!("{}/**", result.trim_end_matches('/'));
121        }
122
123        // Handle patterns that don't start with / or **/
124        if !result.starts_with('/') && !result.starts_with("**/") && !result.contains('/') {
125            // Simple filename pattern - make it match anywhere
126            result = format!("**/{}", result);
127        }
128
129        result
130    }
131
132    /// Check if a file path should be excluded based on the .vtcodegitignore patterns
133    pub fn should_exclude(&self, file_path: &Path) -> bool {
134        if !self.loaded || self.patterns.is_empty() {
135            return false;
136        }
137
138        // Convert to relative path from the root directory
139        let relative_path = match file_path.strip_prefix(&self.root_dir) {
140            Ok(rel) => rel,
141            Err(_) => {
142                // If we can't make it relative, use the full path
143                file_path
144            }
145        };
146
147        let path_str = relative_path.to_string_lossy();
148
149        // Default to not excluded
150        let mut excluded = false;
151
152        for pattern in &self.patterns {
153            if pattern.pattern.matches(&path_str) {
154                if pattern.original.ends_with('/') && file_path.is_file() {
155                    // Directory-only rules should not exclude individual files.
156                    continue;
157                }
158                if pattern.negated {
159                    // Negation pattern - include this file
160                    excluded = false;
161                } else {
162                    // Normal pattern - exclude this file
163                    excluded = true;
164                }
165            }
166        }
167
168        excluded
169    }
170
171    /// Filter a list of file paths based on .vtcodegitignore patterns
172    pub fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
173        if !self.loaded {
174            return paths;
175        }
176
177        paths
178            .into_iter()
179            .filter(|path| !self.should_exclude(path))
180            .collect()
181    }
182
183    /// Check if the .vtcodegitignore file was loaded successfully
184    pub fn is_loaded(&self) -> bool {
185        self.loaded
186    }
187
188    /// Get the number of patterns loaded
189    pub fn pattern_count(&self) -> usize {
190        self.patterns.len()
191    }
192
193    /// Get the root directory
194    pub fn root_dir(&self) -> &Path {
195        &self.root_dir
196    }
197}
198
199impl Default for VTCodeGitignore {
200    fn default() -> Self {
201        Self {
202            root_dir: PathBuf::new(),
203            patterns: Vec::new(),
204            loaded: false,
205        }
206    }
207}
208
209/// Global .vtcodegitignore instance for easy access
210pub static VTCODE_GITIGNORE: once_cell::sync::Lazy<tokio::sync::RwLock<VTCodeGitignore>> =
211    once_cell::sync::Lazy::new(|| tokio::sync::RwLock::new(VTCodeGitignore::default()));
212
213/// Initialize the global .vtcodegitignore instance
214pub async fn initialize_vtcode_gitignore() -> Result<()> {
215    let gitignore = VTCodeGitignore::new().await?;
216    let mut global_gitignore = VTCODE_GITIGNORE.write().await;
217    *global_gitignore = gitignore;
218    Ok(())
219}
220
221/// Get the global .vtcodegitignore instance
222pub async fn get_global_vtcode_gitignore() -> tokio::sync::RwLockReadGuard<'static, VTCodeGitignore>
223{
224    VTCODE_GITIGNORE.read().await
225}
226
227/// Check if a file should be excluded by the global .vtcodegitignore
228pub async fn should_exclude_file(file_path: &Path) -> bool {
229    let gitignore = get_global_vtcode_gitignore().await;
230    gitignore.should_exclude(file_path)
231}
232
233/// Filter paths using the global .vtcodegitignore
234pub async fn filter_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
235    let gitignore = get_global_vtcode_gitignore().await;
236    gitignore.filter_paths(paths)
237}
238
239/// Reload the global .vtcodegitignore from disk
240pub async fn reload_vtcode_gitignore() -> Result<()> {
241    initialize_vtcode_gitignore().await
242}