1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
#![expect(
unused_results,
reason = "WalkBuilder configuration uses fluent setters only for their mutation side effects."
)]
//! Shared directory walker helpers built on the `ignore` crate.
//!
//! All file traversal in vtcode should go through these builders so that
//! `.gitignore`, `.ignore`, `.git/exclude`, and the centralized exclusion
//! constants are applied consistently.
use ignore::{DirEntry, WalkBuilder};
use std::path::Path;
use crate::exclusions::{DEFAULT_EXCLUDED_DIRS, VTCODE_IGNORE_FILE};
/// Build a multi-threaded [`WalkBuilder`] with sensible defaults.
///
/// - Respects `.gitignore`, `.ignore`, `.git/exclude`, and parent ignore files
/// - Does not follow symlinks
/// - Uses the `ignore` crate's default thread pool
///
/// Callers that need to prune additional directories should use
/// [`filter_entry`](WalkBuilder::filter_entry) with [`is_excluded_dir`].
pub fn build_default_walker(root: &Path) -> WalkBuilder {
let mut builder = WalkBuilder::new(root);
apply_defaults(&mut builder);
builder
}
/// Build a [`WalkBuilder`] with the same defaults as [`build_default_walker`]
/// plus `threads(1)`.
///
/// `threads(1)` only constrains `build_parallel()`: the sequential `build()`
/// never spawns the `ignore` thread pool (verified against the `ignore 0.4.34`
/// source, where only `build_parallel` reads the setting), so this helper is
/// equivalent to [`build_default_walker`] for `.build()` callers. Prefer it
/// only when the walker may go parallel.
pub fn build_walker_single_threaded(root: &Path) -> WalkBuilder {
let mut builder = WalkBuilder::new(root);
builder.threads(1);
apply_defaults(&mut builder);
builder
}
/// Apply standard walker defaults to an existing [`WalkBuilder`].
///
/// Sets gitignore support, hidden file visibility, and symlink policy.
/// Callers that need additional customization (e.g., parallel walkers,
/// symlink following) can call this then override specific settings.
pub fn apply_defaults(builder: &mut WalkBuilder) {
// Respect all standard ignore-file mechanisms.
builder.git_ignore(true);
builder.git_global(true);
builder.git_exclude(true);
builder.ignore(true);
builder.parents(true);
// `.vtcodegitignore` mirrors `.gitignore` but is scoped to VT Code's own
// file operations. It has higher precedence than the standard ignore files
// (including its `!` re-include rules), so a user can whitelist a path that
// `.gitignore` prunes.
builder.add_custom_ignore_filename(VTCODE_IGNORE_FILE);
// Do not follow symlinks by default.
builder.follow_links(false);
// Do not skip hidden files by default. The `ignore` crate skips them
// by default, but the previous traversal code did not. Callers that
// want to hide dotfiles should filter them explicitly.
builder.hidden(false);
}
/// Whether a `read_dir` entry is a directory, without paying for a `stat`.
///
/// `readdir` already reports each entry's own type, so `Path::is_dir` re-asks
/// the kernel for a fact in hand (measured ~2 µs/entry vs ~14 ns). A symlink's
/// own type says nothing about its target, and callers have always judged the
/// target, so symlinks fall back to the resolving call.
pub fn entry_is_dir(entry: &std::fs::DirEntry) -> bool {
match entry.file_type() {
Ok(file_type) if !file_type.is_symlink() => file_type.is_dir(),
_ => entry.path().is_dir(),
}
}
/// Whether a `read_dir` entry is a regular file, without paying for a `stat`.
///
/// Same reasoning and symlink rule as [`entry_is_dir`].
pub fn entry_is_file(entry: &std::fs::DirEntry) -> bool {
match entry.file_type() {
Ok(file_type) if !file_type.is_symlink() => file_type.is_file(),
_ => entry.path().is_file(),
}
}
/// Returns `true` if `entry` is a directory whose name appears in
/// [`DEFAULT_EXCLUDED_DIRS`].
///
/// Intended for use inside [`WalkBuilder::filter_entry`] closures:
///
/// ```ignore
/// builder.filter_entry(|entry| !vtcode_commons::walk::is_excluded_dir(entry));
/// ```
pub fn is_excluded_dir(entry: &DirEntry) -> bool {
if !entry.file_type().is_some_and(|ft| ft.is_dir()) {
return false;
}
entry
.file_name()
.to_str()
.is_some_and(|name| DEFAULT_EXCLUDED_DIRS.contains(&name))
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
fn collected_paths(root: &Path) -> Vec<String> {
let walker = build_default_walker(root).build();
let mut paths = walker
.filter_map(Result::ok)
.filter(|entry| entry.path() != root)
.map(|entry| {
entry
.path()
.strip_prefix(root)
.unwrap_or_else(|_| entry.path())
.to_string_lossy()
.into_owned()
})
.collect::<Vec<_>>();
paths.sort();
paths
}
#[test]
fn entry_type_helpers_match_the_resolving_answer() {
let temp = tempdir().expect("tempdir");
let root = temp.path();
fs::create_dir(root.join("dir")).expect("mkdir");
fs::write(root.join("file.txt"), "x").expect("write file");
for entry in fs::read_dir(root).expect("read_dir").flatten() {
let path = entry.path();
// The cheap dirent-based answer must agree with the stat-based one.
assert_eq!(entry_is_dir(&entry), path.is_dir(), "dir mismatch for {}", path.display());
assert_eq!(entry_is_file(&entry), path.is_file(), "file mismatch for {}", path.display());
}
}
#[cfg(unix)]
#[test]
fn entry_type_helpers_resolve_symlink_targets() {
use std::os::unix::fs::symlink;
let temp = tempdir().expect("tempdir");
let root = temp.path();
fs::create_dir(root.join("real-dir")).expect("mkdir");
fs::write(root.join("real-file.txt"), "x").expect("write file");
symlink(root.join("real-dir"), root.join("link-dir")).expect("dir symlink");
symlink(root.join("real-file.txt"), root.join("link-file")).expect("file symlink");
for entry in fs::read_dir(root).expect("read_dir").flatten() {
let path = entry.path();
// A symlink's own type is never a dir/file; the target decides, and
// these helpers must keep matching the previous behavior.
assert_eq!(entry_is_dir(&entry), path.is_dir(), "dir mismatch for {}", path.display());
assert_eq!(entry_is_file(&entry), path.is_file(), "file mismatch for {}", path.display());
}
}
#[test]
fn default_walker_respects_vtcodegitignore() {
let temp = tempdir().expect("tempdir");
let root = temp.path();
fs::write(root.join(".vtcodegitignore"), "ignored_dir/\nignored_file.txt\n").expect("write ignore file");
fs::create_dir(root.join("ignored_dir")).expect("mkdir ignored_dir");
fs::write(root.join("ignored_dir/secret.rs"), "x").expect("write secret");
fs::write(root.join("ignored_file.txt"), "x").expect("write ignored file");
fs::write(root.join("kept.rs"), "x").expect("write kept file");
let paths = collected_paths(root);
assert!(paths.contains(&"kept.rs".to_owned()), "kept file should remain: {paths:?}");
assert!(!paths.iter().any(|p| p.contains("ignored_file.txt")), "ignored file leaked: {paths:?}");
assert!(!paths.iter().any(|p| p.contains("ignored_dir")), "ignored dir leaked: {paths:?}");
}
#[test]
fn default_walker_vtcodegitignore_negation_reincludes() {
let temp = tempdir().expect("tempdir");
let root = temp.path();
// Exclude every log, then re-include one. The custom-ignore file's
// negation must win over its own earlier pattern (mirrors the repo's
// `!README.md` style allow-list).
fs::write(root.join(".vtcodegitignore"), "*.log\n!important.log\n").expect("write ignore file");
fs::write(root.join("important.log"), "x").expect("write important log");
fs::write(root.join("noise.log"), "x").expect("write noise log");
fs::write(root.join("kept.rs"), "x").expect("write kept file");
let paths = collected_paths(root);
assert!(paths.contains(&"important.log".to_owned()), "negated file should be re-included: {paths:?}");
assert!(!paths.iter().any(|p| p.ends_with("noise.log")), "non-negated file leaked: {paths:?}");
assert!(paths.contains(&"kept.rs".to_owned()), "unrelated file should remain: {paths:?}");
}
}