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