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