1use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14use crate::Host;
15use crate::fs::FsError;
16
17#[derive(Debug, Clone, Copy, Default)]
19pub struct WalkOptions {
20 pub respect_gitignore: bool,
23 pub max_depth: Option<usize>,
26 pub max_entries: Option<usize>,
30}
31
32#[derive(Debug, Clone)]
34pub struct WalkEntry {
35 pub path: PathBuf,
37 pub depth: usize,
39 pub is_dir: bool,
41}
42
43pub(crate) struct GitignoreCache(OnceLock<Option<(ignore::gitignore::Gitignore, PathBuf)>>);
46
47impl GitignoreCache {
48 pub(crate) const fn new() -> Self {
49 Self(OnceLock::new())
50 }
51}
52
53impl Clone for GitignoreCache {
54 fn clone(&self) -> Self {
55 let cell = OnceLock::new();
56 if let Some(v) = self.0.get() {
57 let _ = cell.set(v.clone());
58 }
59 Self(cell)
60 }
61}
62
63impl std::fmt::Debug for GitignoreCache {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.write_str("GitignoreCache")
66 }
67}
68
69fn find_git_root(start: &Path) -> Option<PathBuf> {
71 let mut dir = Some(start);
72 while let Some(d) = dir {
73 if d.join(".git").exists() {
74 return Some(d.to_path_buf());
75 }
76 dir = d.parent();
77 }
78 None
79}
80
81impl Host {
82 pub async fn walk(
91 &self,
92 cwd: &Path,
93 path: &Path,
94 opts: WalkOptions,
95 ) -> Result<Vec<WalkEntry>, FsError> {
96 let root = self.resolve_in_jail(cwd, path).await?;
97 tokio::fs::metadata(&root).await.map_err(|e| FsError::Io {
100 op: "walk",
101 path: root.display().to_string(),
102 source: e,
103 })?;
104 let entries = tokio::task::spawn_blocking(move || {
105 let mut builder = ignore::WalkBuilder::new(&root);
106 builder
107 .standard_filters(true)
108 .git_ignore(opts.respect_gitignore)
109 .git_global(opts.respect_gitignore)
110 .git_exclude(opts.respect_gitignore);
111 if let Some(depth) = opts.max_depth {
112 builder.max_depth(Some(depth));
113 }
114 let mut out = Vec::new();
115 for entry in builder.build() {
116 let Ok(entry) = entry else { continue };
117 if entry.depth() == 0 {
118 continue; }
120 let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
121 let depth = entry.depth();
122 out.push(WalkEntry {
123 path: entry.into_path(),
124 depth,
125 is_dir,
126 });
127 if let Some(cap) = opts.max_entries
128 && out.len() >= cap
129 {
130 break;
131 }
132 }
133 out
134 })
135 .await
136 .map_err(|e| FsError::Io {
137 op: "walk",
138 path: String::new(),
139 source: std::io::Error::other(e),
140 })?;
141 Ok(entries)
142 }
143
144 pub async fn is_path_ignored(&self, cwd: &Path, path: &Path) -> Result<bool, FsError> {
157 let resolved = self.resolve_in_jail(cwd, path).await?;
158 let cached = self.gitignore.0.get_or_init(|| {
159 let git_root = find_git_root(&self.workspace_root)?;
160 let mut builder = ignore::gitignore::GitignoreBuilder::new(&git_root);
161 let _ = builder.add(git_root.join(".gitignore"));
162 let _ = builder.add(git_root.join(".git").join("info").join("exclude"));
163 let gitignore = builder.build().ok()?;
164 Some((gitignore, git_root))
165 });
166 let Some((gitignore, _git_root)) = cached else {
167 return Ok(false);
168 };
169 let normalized = std::fs::canonicalize(&resolved).unwrap_or_else(|_| {
172 resolved
173 .parent()
174 .and_then(|parent| {
175 std::fs::canonicalize(parent)
176 .ok()
177 .map(|p| p.join(resolved.file_name().unwrap_or_default()))
178 })
179 .unwrap_or_else(|| resolved.clone())
180 });
181 let is_dir = normalized.is_dir();
182 Ok(gitignore
183 .matched_path_or_any_parents(&normalized, is_dir)
184 .is_ignore())
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::HostConfig;
192
193 fn host_over(dir: &Path) -> Host {
194 Host::new(HostConfig::new(dir)).unwrap()
195 }
196
197 fn git_workspace() -> tempfile::TempDir {
198 let dir = tempfile::tempdir().unwrap();
199 std::fs::create_dir(dir.path().join(".git")).unwrap();
200 std::fs::write(dir.path().join(".gitignore"), "target/\n*.log\n").unwrap();
201 std::fs::create_dir(dir.path().join("src")).unwrap();
202 std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
203 std::fs::create_dir(dir.path().join("target")).unwrap();
204 std::fs::write(dir.path().join("target/out.o"), "o").unwrap();
205 std::fs::write(dir.path().join("app.log"), "log").unwrap();
206 dir
207 }
208
209 #[tokio::test]
210 async fn walk_respects_gitignore_when_asked() {
211 let dir = git_workspace();
212 let host = host_over(dir.path());
213 let root = host.workspace_root().to_path_buf();
214 let opts = WalkOptions {
215 respect_gitignore: true,
216 ..WalkOptions::default()
217 };
218 let entries = host.walk(&root, Path::new("."), opts).await.unwrap();
219 let names: Vec<String> = entries
220 .iter()
221 .map(|e| e.path.strip_prefix(&root).unwrap().display().to_string())
222 .collect();
223 assert!(names.contains(&"src/main.rs".to_string()), "{names:?}");
224 assert!(!names.iter().any(|n| n.starts_with("target")), "{names:?}");
225 assert!(!names.contains(&"app.log".to_string()), "{names:?}");
226 }
227
228 #[tokio::test]
229 async fn walk_includes_ignored_when_not_asked_and_honors_depth() {
230 let dir = git_workspace();
231 let host = host_over(dir.path());
232 let root = host.workspace_root().to_path_buf();
233 let opts = WalkOptions {
234 respect_gitignore: false,
235 max_depth: Some(1),
236 max_entries: None,
237 };
238 let entries = host.walk(&root, Path::new("."), opts).await.unwrap();
239 assert!(entries.iter().all(|e| e.depth == 1), "depth-1 seed only");
240 let names: Vec<String> = entries
241 .iter()
242 .map(|e| e.path.file_name().unwrap().to_string_lossy().into_owned())
243 .collect();
244 assert!(names.contains(&"app.log".to_string()), "{names:?}");
245 assert!(names.contains(&"target".to_string()), "{names:?}");
246 assert!(
247 !names.contains(&".git".to_string()),
248 "standard filters skip .git"
249 );
250 }
251
252 #[tokio::test]
253 async fn is_path_ignored_matches_and_allows() {
254 let dir = git_workspace();
255 let host = host_over(dir.path());
256 let root = host.workspace_root().to_path_buf();
257 assert!(
258 host.is_path_ignored(&root, Path::new("app.log"))
259 .await
260 .unwrap()
261 );
262 assert!(
263 host.is_path_ignored(&root, Path::new("target/out.o"))
264 .await
265 .unwrap()
266 );
267 assert!(
268 !host
269 .is_path_ignored(&root, Path::new("src/main.rs"))
270 .await
271 .unwrap()
272 );
273 assert!(
275 host.is_path_ignored(&root, Path::new("new.log"))
276 .await
277 .unwrap()
278 );
279 }
280
281 #[tokio::test]
282 async fn non_git_workspace_allows_everything() {
283 let dir = tempfile::tempdir().unwrap();
284 std::fs::write(dir.path().join("app.log"), "x").unwrap();
285 let host = host_over(dir.path());
286 let root = host.workspace_root().to_path_buf();
287 assert!(
288 !host
289 .is_path_ignored(&root, Path::new("app.log"))
290 .await
291 .unwrap()
292 );
293 }
294}