Skip to main content

locode_host/
walk.rs

1//! Gitignore-aware traversal + single-path ignore checks (Task 26 Slice 0).
2//!
3//! Grok Build is the model: its `list_dir` walks with
4//! `WalkBuilder::standard_filters(true).git_ignore(f).git_global(f).git_exclude(f)`
5//! (`gb/list_dir/mod.rs:282-291`), and its per-path guard is a session-cached
6//! `ignore::gitignore::Gitignore` that allows everything when the workspace is
7//! not a git repo (`gb/types/resources.rs:596-627`). Both live HERE — the host
8//! seam — so pack tools stay free of direct filesystem access (ADR-0008;
9//! plan-finalization Q1, 2026-07-21).
10
11use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14use crate::Host;
15use crate::fs::FsError;
16
17/// Options for [`Host::walk`].
18#[derive(Debug, Clone, Copy, Default)]
19pub struct WalkOptions {
20    /// Honor `.gitignore` / global gitignore / `.git/info/exclude`
21    /// (grok's three `git_*` builder flags move together).
22    pub respect_gitignore: bool,
23    /// Maximum depth below the root (`None` = unbounded; `Some(1)` = direct
24    /// children — grok's depth-1 seed walk).
25    pub max_depth: Option<usize>,
26    /// Stop after collecting this many entries (`None` = unbounded). The walk
27    /// is depth-first in the `ignore` crate's default order; callers impose
28    /// their own ordering/budgeting on the returned entries.
29    pub max_entries: Option<usize>,
30}
31
32/// One traversal entry (the root itself is not reported).
33#[derive(Debug, Clone)]
34pub struct WalkEntry {
35    /// Absolute path.
36    pub path: PathBuf,
37    /// Depth below the walk root (direct children = 1).
38    pub depth: usize,
39    /// Whether the entry is a directory.
40    pub is_dir: bool,
41}
42
43/// The lazily-built workspace gitignore matcher: `None` when the workspace is
44/// not inside a git repo (allow-all, grok's rule).
45pub(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
69/// Find the enclosing git root (a dir containing `.git`) at or above `start`.
70fn 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    /// Walk `path` (jail-resolved against `cwd`) with grok-style standard
83    /// filters. Hidden files and `.git` are always skipped
84    /// (`standard_filters(true)`); the three gitignore sources are toggled
85    /// together by [`WalkOptions::respect_gitignore`].
86    ///
87    /// # Errors
88    /// [`FsError::Path`] when the jail rejects `path`; [`FsError::Io`] when
89    /// the root does not exist or is unreadable.
90    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        // Surface a missing/unreadable root as the usual Io error (the walker
98        // itself yields per-entry errors we skip).
99        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; // the root itself
119                }
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    /// Whether `path` (jail-resolved against `cwd`) is gitignored relative to
145    /// the workspace's git root. `false` when the workspace is not a git repo
146    /// (grok's allow-all rule). Non-existent paths fall back to their parent
147    /// for resolution (grok's new-file-creation case).
148    ///
149    /// The matcher is built once per host from the git root's `.gitignore` and
150    /// `.git/info/exclude`. Known limitation vs grok (documented): nested
151    /// `.gitignore` files in subdirectories are not consulted here — the
152    /// [`Host::walk`] path handles them fully via the walker.
153    ///
154    /// # Errors
155    /// [`FsError::Path`] when the jail rejects `path`.
156    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        // grok's normalization: canonicalize, falling back to parent + file
170        // name for paths that don't exist yet (`resources.rs:615-624`).
171        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        // Non-existent (new-file) path under an ignored pattern.
274        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}