Skip to main content

drft/sources/
fs.rs

1//! The `fs` source: a `.gitignore`-aware filesystem walk that yields one
2//! [`SourceFile`] per file, symlink, and directory under the graph root.
3
4use anyhow::Result;
5use globset::GlobSet;
6use ignore::WalkBuilder;
7use std::path::Path;
8
9use crate::config::compile_globs;
10
11/// VCS metadata entries pruned from the walk. The hidden filter is off so that
12/// ordinary dot-directories (`.github/`, `.config/`) join the graph, but a
13/// version-control store is internal bookkeeping, never graph content — so it
14/// is excluded by name. ripgrep skips these via its hidden filter; drft keeps
15/// dot-dirs and names the exclusions instead.
16const VCS_DIRS: [&str; 4] = [".git", ".hg", ".svn", ".jj"];
17
18/// What kind of filesystem entry a [`SourceFile`] is. Derived from `lstat`, so a
19/// symlink-to-directory is [`Symlink`](NodeKind::Symlink) (indirection wins over
20/// target kind), and [`Dir`](NodeKind::Dir) always means a real directory.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum NodeKind {
23    File,
24    Symlink,
25    Dir,
26}
27
28/// An entry delivered by a source: its graph-relative path, kind, and — for
29/// files — its raw bytes.
30pub struct SourceFile {
31    /// Path relative to the graph root, with forward slashes.
32    pub path: String,
33    /// The kind of entry, from `lstat`.
34    pub kind: NodeKind,
35    /// Raw content. `Some` only for files (and `None` for one that could not be
36    /// read). Symlinks and directories are untrackable and always carry `None`.
37    pub bytes: Option<Vec<u8>>,
38}
39
40/// Walk the tree under `root`, honoring `.gitignore` and the `ignore` globs,
41/// yielding one [`SourceFile`] per file, symlink, and directory. Paths are
42/// relative to `root`, sorted.
43///
44/// Hidden entries are *not* skipped: a dot-directory like `.github/` is part of
45/// the graph. The lone exception is VCS metadata ([`VCS_DIRS`]), pruned from
46/// traversal — `.git/` would otherwise flood the graph with internal state.
47///
48/// Only committed `.gitignore` rules prune the walk. The user's global
49/// gitignore, the per-clone `.git/info/exclude`, and ignore files above `root`
50/// are all ignored, so the graph depends only on what is committed at the root.
51///
52/// The walk does not follow symlinks: a symlink is a leaf node at its own path,
53/// never traversed through. Its relationship to its target is carried by the
54/// edge the builder emits, not by re-walking the target. So a symlink to a
55/// directory does not duplicate that directory's subtree, and a symlink to a
56/// path outside the root never pulls outside content into the graph.
57///
58/// Only files carry bytes (and therefore a hash). Symlinks and directories are
59/// untrackable: they resolve link targets but are never hashed or locked.
60pub fn walk(root: &Path, ignore: &[String]) -> Result<Vec<SourceFile>> {
61    let ignore_set = compile_globs(ignore)?;
62
63    let mut files = Vec::new();
64
65    // Reproducibility over convenience: the graph must depend only on what is
66    // committed at the root, never on machine-local or above-root state. So,
67    // unlike ripgrep's defaults, drft honors committed `.gitignore` but not the
68    // user's global gitignore, the per-clone `.git/info/exclude`, or ignore
69    // files in directories above the declared root.
70    let walker = WalkBuilder::new(root)
71        .follow_links(false)
72        .hidden(false)
73        .parents(false)
74        .git_global(false)
75        .git_exclude(false)
76        .filter_entry(|entry| {
77            // With the hidden filter off, dot-directories are walked. Prune VCS
78            // metadata explicitly so it never enters the graph. `.git` can be a
79            // file (submodules, linked worktrees) as well as a directory, so
80            // match by name regardless of kind.
81            entry
82                .file_name()
83                .to_str()
84                .is_none_or(|name| !VCS_DIRS.contains(&name))
85        })
86        .build();
87
88    for entry in walker {
89        let entry = entry?;
90        let ft = entry.file_type();
91        // Yield files, symlinks, and directories; skip fifos, sockets, and other
92        // entries. With symlinks unfollowed, a symlink reports its own type here.
93        if !ft.is_some_and(|t| t.is_file() || t.is_dir() || t.is_symlink()) {
94            continue;
95        }
96
97        let relative = entry
98            .path()
99            .strip_prefix(root)
100            .expect("path should be under root")
101            .to_string_lossy()
102            .replace('\\', "/");
103
104        // The root itself is the graph, not a node in it.
105        if relative.is_empty() {
106            continue;
107        }
108
109        // Type from `lstat`, symlink first: a symlink-to-dir is a symlink, and
110        // `Dir` always means a real directory.
111        let kind = match abs_lstat(root, &relative) {
112            Some(m) if m.file_type().is_symlink() => NodeKind::Symlink,
113            Some(m) if m.is_dir() => NodeKind::Dir,
114            _ => NodeKind::File,
115        };
116
117        if is_ignored(&ignore_set, &relative, kind) {
118            continue;
119        }
120
121        // Only files carry content. A symlink's content is its target's, reached
122        // through the edge — the symlink node itself stays untrackable.
123        let bytes = match kind {
124            NodeKind::File => std::fs::read(root.join(&relative)).ok(),
125            NodeKind::Symlink | NodeKind::Dir => None,
126        };
127
128        files.push(SourceFile {
129            path: relative,
130            kind,
131            bytes,
132        });
133    }
134
135    files.sort_by(|a, b| a.path.cmp(&b.path));
136    Ok(files)
137}
138
139/// `lstat` the entry at `root/relative` without following symlinks.
140fn abs_lstat(root: &Path, relative: &str) -> Option<std::fs::Metadata> {
141    root.join(relative).symlink_metadata().ok()
142}
143
144/// Whether an entry is excluded by the `ignore` globs. Files match the path
145/// as-is. A directory is also excluded when a glob matches its path with a
146/// trailing slash — so `examples/**` (which matches `examples/`, not `examples`)
147/// drops the `examples` directory node, while `docs/*.md` leaves `docs` intact.
148fn is_ignored(ignore_set: &Option<GlobSet>, relative: &str, kind: NodeKind) -> bool {
149    let Some(set) = ignore_set else {
150        return false;
151    };
152    set.is_match(relative) || (kind == NodeKind::Dir && set.is_match(format!("{relative}/")))
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::fs;
159    use tempfile::TempDir;
160
161    #[test]
162    fn walks_all_files_sorted() {
163        let dir = TempDir::new().unwrap();
164        fs::write(dir.path().join("b.md"), "b").unwrap();
165        fs::write(dir.path().join("a.md"), "a").unwrap();
166        fs::write(dir.path().join("notes.txt"), "n").unwrap();
167
168        let files = walk(dir.path(), &[]).unwrap();
169        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
170        assert_eq!(paths, vec!["a.md", "b.md", "notes.txt"]);
171        assert_eq!(files[0].bytes.as_deref(), Some(&b"a"[..]));
172    }
173
174    #[test]
175    fn respects_ignore_globs() {
176        let dir = TempDir::new().unwrap();
177        fs::write(dir.path().join("keep.md"), "k").unwrap();
178        let sub = dir.path().join("target");
179        fs::create_dir(&sub).unwrap();
180        fs::write(sub.join("build.md"), "b").unwrap();
181
182        let files = walk(dir.path(), &["target/**".to_string()]).unwrap();
183        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
184        // `target/**` drops both the build file and the `target` directory node
185        // (it matches `target/`), leaving only the kept file.
186        assert_eq!(paths, vec!["keep.md"]);
187    }
188
189    #[test]
190    fn yields_directory_nodes() {
191        let dir = TempDir::new().unwrap();
192        fs::create_dir(dir.path().join("guides")).unwrap();
193        fs::write(dir.path().join("guides/intro.md"), "i").unwrap();
194
195        let files = walk(dir.path(), &[]).unwrap();
196        let guides = files.iter().find(|f| f.path == "guides").unwrap();
197        assert_eq!(guides.kind, NodeKind::Dir);
198        assert!(guides.bytes.is_none(), "directories carry no content");
199
200        let intro = files.iter().find(|f| f.path == "guides/intro.md").unwrap();
201        assert_eq!(intro.kind, NodeKind::File);
202    }
203
204    #[test]
205    fn includes_empty_directories() {
206        let dir = TempDir::new().unwrap();
207        fs::create_dir(dir.path().join("empty")).unwrap();
208
209        let files = walk(dir.path(), &[]).unwrap();
210        assert!(
211            files
212                .iter()
213                .any(|f| f.path == "empty" && f.kind == NodeKind::Dir)
214        );
215    }
216
217    #[test]
218    fn root_is_not_a_node() {
219        let dir = TempDir::new().unwrap();
220        fs::write(dir.path().join("a.md"), "a").unwrap();
221
222        let files = walk(dir.path(), &[]).unwrap();
223        assert!(
224            !files.iter().any(|f| f.path.is_empty()),
225            "the graph root must not appear as a node"
226        );
227    }
228
229    #[test]
230    fn respects_gitignore() {
231        let dir = TempDir::new().unwrap();
232        fs::create_dir(dir.path().join(".git")).unwrap();
233        fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
234        fs::write(dir.path().join("index.md"), "i").unwrap();
235        let vendor = dir.path().join("vendor");
236        fs::create_dir(&vendor).unwrap();
237        fs::write(vendor.join("lib.md"), "v").unwrap();
238
239        let files = walk(dir.path(), &[]).unwrap();
240        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
241        assert!(paths.contains(&"index.md"));
242        assert!(!paths.iter().any(|p| p.contains("vendor")));
243    }
244
245    #[test]
246    fn ignore_files_above_root_do_not_prune_the_walk() {
247        // A git repo whose root sits *above* the graph root, with a gitignore
248        // that would exclude `keep.md`. Because `parents` is off, the rule above
249        // the declared root has no effect — the graph is self-contained.
250        let outer = TempDir::new().unwrap();
251        fs::create_dir(outer.path().join(".git")).unwrap();
252        fs::write(outer.path().join(".gitignore"), "keep.md\n").unwrap();
253        let root = outer.path().join("project");
254        fs::create_dir(&root).unwrap();
255        fs::write(root.join("keep.md"), "k").unwrap();
256
257        let files = walk(&root, &[]).unwrap();
258        assert!(
259            files.iter().any(|f| f.path == "keep.md"),
260            "an ignore rule above the root must not prune the walk"
261        );
262    }
263
264    #[test]
265    fn dot_dirs_are_walked_but_vcs_dirs_are_pruned() {
266        let dir = TempDir::new().unwrap();
267        // A real version-control store: pruned, contents and all.
268        fs::create_dir(dir.path().join(".git")).unwrap();
269        fs::write(dir.path().join(".git").join("HEAD"), "ref: x").unwrap();
270        // An ordinary dot-directory: part of the graph.
271        fs::create_dir(dir.path().join(".github")).unwrap();
272        fs::write(dir.path().join(".github").join("ci.yml"), "y").unwrap();
273
274        let files = walk(dir.path(), &[]).unwrap();
275        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
276
277        assert!(
278            !paths.iter().any(|p| p.starts_with(".git/") || *p == ".git"),
279            "VCS metadata must be pruned, got: {paths:?}"
280        );
281        assert!(
282            paths.contains(&".github") && paths.contains(&".github/ci.yml"),
283            "ordinary dot-directories must be walked, got: {paths:?}"
284        );
285    }
286
287    #[cfg(unix)]
288    #[test]
289    fn symlink_escaping_root_has_no_bytes() {
290        let outer = TempDir::new().unwrap();
291        let root = outer.path().join("project");
292        fs::create_dir(&root).unwrap();
293        fs::write(root.join("index.md"), "i").unwrap();
294        fs::write(outer.path().join("secret.md"), "secret").unwrap();
295        std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
296
297        let files = walk(&root, &[]).unwrap();
298        let trap = files.iter().find(|f| f.path == "trap.md").unwrap();
299        assert!(
300            trap.bytes.is_none(),
301            "escaping symlink should carry no bytes"
302        );
303    }
304
305    #[cfg(unix)]
306    #[test]
307    fn symlink_to_directory_is_typed_symlink() {
308        // Indirection wins over target kind: a symlink pointing at a directory is
309        // a `Symlink`, not a `Dir`. The link's target resolves through the edge
310        // the builder emits, not the node's type.
311        let dir = TempDir::new().unwrap();
312        fs::create_dir(dir.path().join("real")).unwrap();
313        std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
314
315        let files = walk(dir.path(), &[]).unwrap();
316        let alias = files.iter().find(|f| f.path == "alias").unwrap();
317        assert_eq!(alias.kind, NodeKind::Symlink);
318        let real = files.iter().find(|f| f.path == "real").unwrap();
319        assert_eq!(real.kind, NodeKind::Dir);
320    }
321
322    #[cfg(unix)]
323    #[test]
324    fn symlink_to_file_carries_no_bytes() {
325        // A symlink is pure indirection: its node is never hashed. Content lives
326        // at the real path; the builder's edge carries the relationship.
327        let dir = TempDir::new().unwrap();
328        fs::write(dir.path().join("real.md"), "content").unwrap();
329        std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
330            .unwrap();
331
332        let files = walk(dir.path(), &[]).unwrap();
333        let alias = files.iter().find(|f| f.path == "alias.md").unwrap();
334        assert_eq!(alias.kind, NodeKind::Symlink);
335        assert!(alias.bytes.is_none(), "symlink node must carry no bytes");
336        let real = files.iter().find(|f| f.path == "real.md").unwrap();
337        assert_eq!(real.bytes.as_deref(), Some(&b"content"[..]));
338    }
339
340    #[cfg(unix)]
341    #[test]
342    fn does_not_descend_into_symlinked_directory() {
343        // A symlinked directory is a leaf node, not a second copy of the subtree.
344        let dir = TempDir::new().unwrap();
345        fs::create_dir(dir.path().join("real")).unwrap();
346        fs::write(dir.path().join("real/child.md"), "c").unwrap();
347        std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
348
349        let files = walk(dir.path(), &[]).unwrap();
350        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
351        assert!(paths.contains(&"alias"), "the symlink itself is a node");
352        assert!(
353            paths.contains(&"real/child.md"),
354            "real content appears once"
355        );
356        assert!(
357            !paths.contains(&"alias/child.md"),
358            "must not re-walk the target subtree through the symlink, got: {paths:?}"
359        );
360    }
361
362    #[cfg(unix)]
363    #[test]
364    fn does_not_leak_content_through_escaping_directory_symlink() {
365        // A symlink to a directory outside the root must not pull outside files
366        // into the graph as readable nodes.
367        let outer = TempDir::new().unwrap();
368        let root = outer.path().join("project");
369        fs::create_dir(&root).unwrap();
370        fs::create_dir(outer.path().join("secrets")).unwrap();
371        fs::write(outer.path().join("secrets/passwd.md"), "TOP SECRET").unwrap();
372        std::os::unix::fs::symlink(outer.path().join("secrets"), root.join("alias")).unwrap();
373
374        let files = walk(&root, &[]).unwrap();
375        assert!(
376            !files.iter().any(|f| f.path.contains("passwd")),
377            "outside content must not be walked through a symlink"
378        );
379    }
380}