use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::Host;
use crate::fs::FsError;
#[derive(Debug, Clone, Copy, Default)]
pub struct WalkOptions {
pub respect_gitignore: bool,
pub max_depth: Option<usize>,
pub max_entries: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct WalkEntry {
pub path: PathBuf,
pub depth: usize,
pub is_dir: bool,
}
pub(crate) struct GitignoreCache(OnceLock<Option<(ignore::gitignore::Gitignore, PathBuf)>>);
impl GitignoreCache {
pub(crate) const fn new() -> Self {
Self(OnceLock::new())
}
}
impl Clone for GitignoreCache {
fn clone(&self) -> Self {
let cell = OnceLock::new();
if let Some(v) = self.0.get() {
let _ = cell.set(v.clone());
}
Self(cell)
}
}
impl std::fmt::Debug for GitignoreCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("GitignoreCache")
}
}
fn find_git_root(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join(".git").exists() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
impl Host {
pub async fn walk(
&self,
cwd: &Path,
path: &Path,
opts: WalkOptions,
) -> Result<Vec<WalkEntry>, FsError> {
let root = self.resolve_in_jail(cwd, path).await?;
tokio::fs::metadata(&root).await.map_err(|e| FsError::Io {
op: "walk",
path: root.display().to_string(),
source: e,
})?;
let entries = tokio::task::spawn_blocking(move || {
let mut builder = ignore::WalkBuilder::new(&root);
builder
.standard_filters(true)
.git_ignore(opts.respect_gitignore)
.git_global(opts.respect_gitignore)
.git_exclude(opts.respect_gitignore);
if let Some(depth) = opts.max_depth {
builder.max_depth(Some(depth));
}
let mut out = Vec::new();
for entry in builder.build() {
let Ok(entry) = entry else { continue };
if entry.depth() == 0 {
continue; }
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
let depth = entry.depth();
out.push(WalkEntry {
path: entry.into_path(),
depth,
is_dir,
});
if let Some(cap) = opts.max_entries
&& out.len() >= cap
{
break;
}
}
out
})
.await
.map_err(|e| FsError::Io {
op: "walk",
path: String::new(),
source: std::io::Error::other(e),
})?;
Ok(entries)
}
pub async fn is_path_ignored(&self, cwd: &Path, path: &Path) -> Result<bool, FsError> {
let resolved = self.resolve_in_jail(cwd, path).await?;
let cached = self.gitignore.0.get_or_init(|| {
let git_root = find_git_root(&self.workspace_root)?;
let mut builder = ignore::gitignore::GitignoreBuilder::new(&git_root);
let _ = builder.add(git_root.join(".gitignore"));
let _ = builder.add(git_root.join(".git").join("info").join("exclude"));
let gitignore = builder.build().ok()?;
Some((gitignore, git_root))
});
let Some((gitignore, _git_root)) = cached else {
return Ok(false);
};
let normalized = std::fs::canonicalize(&resolved).unwrap_or_else(|_| {
resolved
.parent()
.and_then(|parent| {
std::fs::canonicalize(parent)
.ok()
.map(|p| p.join(resolved.file_name().unwrap_or_default()))
})
.unwrap_or_else(|| resolved.clone())
});
let is_dir = normalized.is_dir();
Ok(gitignore
.matched_path_or_any_parents(&normalized, is_dir)
.is_ignore())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
fn host_over(dir: &Path) -> Host {
Host::new(HostConfig::new(dir)).unwrap()
}
fn git_workspace() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(".gitignore"), "target/\n*.log\n").unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::create_dir(dir.path().join("target")).unwrap();
std::fs::write(dir.path().join("target/out.o"), "o").unwrap();
std::fs::write(dir.path().join("app.log"), "log").unwrap();
dir
}
#[tokio::test]
async fn walk_respects_gitignore_when_asked() {
let dir = git_workspace();
let host = host_over(dir.path());
let root = host.workspace_root().to_path_buf();
let opts = WalkOptions {
respect_gitignore: true,
..WalkOptions::default()
};
let entries = host.walk(&root, Path::new("."), opts).await.unwrap();
let names: Vec<String> = entries
.iter()
.map(|e| e.path.strip_prefix(&root).unwrap().display().to_string())
.collect();
assert!(names.contains(&"src/main.rs".to_string()), "{names:?}");
assert!(!names.iter().any(|n| n.starts_with("target")), "{names:?}");
assert!(!names.contains(&"app.log".to_string()), "{names:?}");
}
#[tokio::test]
async fn walk_includes_ignored_when_not_asked_and_honors_depth() {
let dir = git_workspace();
let host = host_over(dir.path());
let root = host.workspace_root().to_path_buf();
let opts = WalkOptions {
respect_gitignore: false,
max_depth: Some(1),
max_entries: None,
};
let entries = host.walk(&root, Path::new("."), opts).await.unwrap();
assert!(entries.iter().all(|e| e.depth == 1), "depth-1 seed only");
let names: Vec<String> = entries
.iter()
.map(|e| e.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"app.log".to_string()), "{names:?}");
assert!(names.contains(&"target".to_string()), "{names:?}");
assert!(
!names.contains(&".git".to_string()),
"standard filters skip .git"
);
}
#[tokio::test]
async fn is_path_ignored_matches_and_allows() {
let dir = git_workspace();
let host = host_over(dir.path());
let root = host.workspace_root().to_path_buf();
assert!(
host.is_path_ignored(&root, Path::new("app.log"))
.await
.unwrap()
);
assert!(
host.is_path_ignored(&root, Path::new("target/out.o"))
.await
.unwrap()
);
assert!(
!host
.is_path_ignored(&root, Path::new("src/main.rs"))
.await
.unwrap()
);
assert!(
host.is_path_ignored(&root, Path::new("new.log"))
.await
.unwrap()
);
}
#[tokio::test]
async fn non_git_workspace_allows_everything() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app.log"), "x").unwrap();
let host = host_over(dir.path());
let root = host.workspace_root().to_path_buf();
assert!(
!host
.is_path_ignored(&root, Path::new("app.log"))
.await
.unwrap()
);
}
}