vtcode_commons/
vtcodegitignore.rs1#![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
7use anyhow::{Result, anyhow};
13use ignore::gitignore::{Gitignore, GitignoreBuilder};
14use std::fs::FileType;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use tokio::fs;
18
19#[derive(Debug, Clone)]
21pub struct VTCodeGitignore {
22 root_dir: PathBuf,
24 canonical_root: Option<PathBuf>,
31 matcher: Gitignore,
33 loaded: bool,
35}
36
37impl VTCodeGitignore {
38 pub async fn new() -> Result<Self> {
40 let current_dir = std::env::current_dir().map_err(|e| anyhow!("Failed to get current directory: {e}"))?;
41
42 Self::from_directory(¤t_dir).await
43 }
44
45 pub async fn from_directory(root_dir: &Path) -> Result<Self> {
47 let gitignore_path = root_dir.join(".vtcodegitignore");
48
49 let mut loaded = false;
50 let mut builder = GitignoreBuilder::new(root_dir);
51
52 if gitignore_path.exists() {
53 match Self::load_patterns(&gitignore_path, &mut builder).await {
54 Ok(()) => {
55 loaded = true;
56 }
57 Err(e) => {
58 tracing::warn!("Failed to load .vtcodegitignore: {}", e);
60 }
61 }
62 }
63
64 let matcher = builder.build().unwrap_or_else(|_| {
65 Gitignore::empty()
67 });
68
69 let canonical_root = crate::paths::canonicalize(root_dir).ok();
71
72 Ok(Self {
73 root_dir: root_dir.to_path_buf(),
74 canonical_root,
75 matcher,
76 loaded,
77 })
78 }
79
80 async fn load_patterns(file_path: &Path, builder: &mut GitignoreBuilder) -> Result<()> {
82 let content = fs::read_to_string(file_path)
83 .await
84 .map_err(|e| anyhow!("Failed to read .vtcodegitignore: {e}"))?;
85
86 for (line_num, line) in content.lines().enumerate() {
87 let line = line.trim();
88
89 if line.is_empty() || line.starts_with('#') {
91 continue;
92 }
93
94 builder
95 .add_line(None, line)
96 .map_err(|e| anyhow!("Invalid pattern on line {}: '{}': {}", line_num + 1, line, e))?;
97 }
98
99 Ok(())
100 }
101
102 pub fn should_exclude(&self, file_path: &Path) -> bool {
104 self.should_exclude_with_is_dir(file_path, file_path.is_dir())
105 }
106
107 pub fn should_exclude_with_file_type(&self, file_path: &Path, file_type: Option<FileType>) -> bool {
116 match file_type {
117 Some(file_type) if !file_type.is_symlink() => {
118 self.should_exclude_with_is_dir(file_path, file_type.is_dir())
119 }
120 _ => self.should_exclude(file_path),
121 }
122 }
123
124 pub fn should_exclude_with_is_dir(&self, file_path: &Path, is_dir: bool) -> bool {
130 if !self.loaded {
131 return false;
132 }
133
134 if let Some(relative) = self.relative_to_root(file_path) {
145 return self.matcher.matched_path_or_any_parents(relative, is_dir).is_ignore();
146 }
147
148 let Some(canonical_root) = self.canonical_root.as_ref() else {
152 return false;
153 };
154 let Ok(canonical_path) = crate::paths::canonicalize(file_path) else {
155 return false;
156 };
157 let Ok(relative) = canonical_path.strip_prefix(canonical_root) else {
158 return false;
159 };
160
161 self.matcher.matched_path_or_any_parents(relative, is_dir).is_ignore()
162 }
163
164 fn relative_to_root<'a>(&self, file_path: &'a Path) -> Option<&'a Path> {
169 file_path
170 .strip_prefix(&self.root_dir)
171 .ok()
172 .or_else(|| self.canonical_root.as_ref().and_then(|root| file_path.strip_prefix(root).ok()))
173 }
174
175 pub fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
177 if !self.loaded {
178 return paths;
179 }
180
181 paths.into_iter().filter(|path| !self.should_exclude(path)).collect()
182 }
183
184 pub fn is_loaded(&self) -> bool {
186 self.loaded
187 }
188
189 pub fn pattern_count(&self) -> usize {
191 self.matcher.num_ignores() as usize
192 }
193
194 pub fn root_dir(&self) -> &Path {
196 &self.root_dir
197 }
198}
199
200impl Default for VTCodeGitignore {
201 fn default() -> Self {
202 let root_dir = PathBuf::new();
203 let matcher = Gitignore::empty();
204 Self {
205 root_dir,
206 canonical_root: None,
207 matcher,
208 loaded: false,
209 }
210 }
211}
212
213static VTCODE_GITIGNORE: once_cell::sync::Lazy<arc_swap::ArcSwap<VTCodeGitignore>> =
221 once_cell::sync::Lazy::new(|| arc_swap::ArcSwap::from_pointee(VTCodeGitignore::default()));
222
223pub async fn initialize_vtcode_gitignore() -> Result<()> {
225 let gitignore = VTCodeGitignore::new().await?;
226 VTCODE_GITIGNORE.store(Arc::new(gitignore));
227 Ok(())
228}
229
230pub async fn snapshot_global_vtcode_gitignore() -> Arc<VTCodeGitignore> {
232 VTCODE_GITIGNORE.load_full()
233}
234
235pub async fn should_exclude_file(file_path: &Path) -> bool {
237 let gitignore = snapshot_global_vtcode_gitignore().await;
238 gitignore.should_exclude(file_path)
239}
240
241pub async fn should_exclude_file_with_is_dir(file_path: &Path, is_dir: bool) -> bool {
246 let gitignore = snapshot_global_vtcode_gitignore().await;
247 gitignore.should_exclude_with_is_dir(file_path, is_dir)
248}
249
250pub async fn should_exclude_file_with_file_type(file_path: &Path, file_type: Option<FileType>) -> bool {
256 let gitignore = snapshot_global_vtcode_gitignore().await;
257 gitignore.should_exclude_with_file_type(file_path, file_type)
258}
259
260pub async fn filter_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
262 let gitignore = snapshot_global_vtcode_gitignore().await;
263 gitignore.filter_paths(paths)
264}
265
266pub async fn reload_vtcode_gitignore() -> Result<()> {
268 initialize_vtcode_gitignore().await
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 async fn matcher_for(root: &Path, patterns: &str) -> VTCodeGitignore {
277 std::fs::write(root.join(".vtcodegitignore"), patterns).expect("write ignore file");
278 VTCodeGitignore::from_directory(root).await.expect("build matcher")
279 }
280
281 #[tokio::test]
282 async fn absolute_path_outside_the_ignore_root_is_not_excluded() {
283 let temp = tempfile::tempdir().unwrap();
284 let root = temp.path().join("workspace");
285 std::fs::create_dir_all(&root).unwrap();
286 let gitignore = matcher_for(&root, "target/\n").await;
287
288 let outside = temp.path().join("elsewhere").join("file.rs");
289 std::fs::create_dir_all(outside.parent().unwrap()).unwrap();
290 std::fs::write(&outside, "x").unwrap();
291
292 assert!(!gitignore.should_exclude(&outside));
296 assert!(!gitignore.should_exclude_with_is_dir(&outside, false));
297 }
298
299 #[cfg(unix)]
300 #[tokio::test]
301 async fn entries_reached_through_a_root_alias_still_match_patterns() {
302 use std::os::unix::fs::symlink;
303
304 let temp = tempfile::tempdir().unwrap();
305 let real_root = temp.path().join("workspace");
306 std::fs::create_dir_all(real_root.join("skipped")).unwrap();
307 let gitignore = matcher_for(&real_root, "skipped/\n").await;
308
309 let alias_root = temp.path().join("workspace-alias");
312 symlink(&real_root, &alias_root).unwrap();
313 let alias_child = alias_root.join("skipped");
314
315 assert!(
316 gitignore.should_exclude_with_is_dir(&alias_child, true),
317 "a directory under an aliased root must still match the pattern"
318 );
319 }
320
321 #[tokio::test]
322 async fn known_file_type_matches_the_stat_based_answer() {
323 let temp = tempfile::tempdir().unwrap();
324 let root = temp.path();
325 std::fs::create_dir(root.join("skipped")).unwrap();
326 std::fs::write(root.join("kept.rs"), "x").unwrap();
327 let gitignore = matcher_for(root, "skipped/\n").await;
328
329 let dir_entry = std::fs::read_dir(root)
330 .unwrap()
331 .flatten()
332 .find(|e| e.file_name() == "skipped")
333 .unwrap();
334 let dir_type = dir_entry.file_type().unwrap();
335 let dir_path = dir_entry.path();
336 assert!(dir_type.is_dir());
337
338 assert_eq!(
341 gitignore.should_exclude_with_file_type(&dir_path, Some(dir_type)),
342 gitignore.should_exclude(&dir_path)
343 );
344 assert!(gitignore.should_exclude_with_file_type(&dir_path, Some(dir_type)));
345
346 let file_type = std::fs::symlink_metadata(root.join("kept.rs")).unwrap().file_type();
347 assert!(!gitignore.should_exclude_with_file_type(&root.join("kept.rs"), Some(file_type)));
348 }
349
350 #[test]
351 fn unknown_file_type_falls_back_to_the_filesystem() {
352 let temp = tempfile::tempdir().unwrap();
353 let root = temp.path();
354 let gitignore = VTCodeGitignore::default();
355 assert!(!gitignore.should_exclude_with_file_type(&root.join("anything"), None));
357 }
358
359 #[cfg(unix)]
360 #[tokio::test]
361 async fn symlink_entries_defer_to_the_target_type() {
362 use std::os::unix::fs::symlink;
363
364 let temp = tempfile::tempdir().unwrap();
365 let root = temp.path();
366 std::fs::create_dir(root.join("real-dir")).unwrap();
367 symlink(root.join("real-dir"), root.join("link-dir")).unwrap();
368 let gitignore = matcher_for(root, "link-dir/\n").await;
369
370 let link = root.join("link-dir");
371 let link_type = std::fs::symlink_metadata(&link).unwrap().file_type();
372 assert!(link_type.is_symlink());
373
374 assert_eq!(gitignore.should_exclude_with_file_type(&link, Some(link_type)), gitignore.should_exclude(&link));
377 }
378}