Skip to main content

vtcode_commons/
vtcodegitignore.rs

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