Skip to main content

ephemeris_files/
walk.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::Path;
4
5use git2::{ErrorCode, Repository as GitRepository};
6
7use crate::FilesError;
8use crate::error::IoResultExt as _;
9use crate::path::{RepositoryPath, RepositoryRoot};
10use crate::repository::Repository;
11
12/// List the regular files Git tracks or would track below `root`.
13///
14/// Inside a Git repository the result matches
15/// `git ls-files --cached --others --exclude-standard` without symbolic
16/// links. Every Git ignore source applies to untracked files.
17///
18/// When no Git repository holds `root`, the result is every regular file
19/// below it, without symbolic links and without `.git` entries. A source
20/// tree without `.git` is already an export of Git's view.
21///
22/// # Errors
23/// Returns an error when `root` lies inside a repository without being its
24/// root, when Git cannot be read or when the filesystem cannot be listed.
25pub fn walk(root: &RepositoryRoot) -> Result<BTreeSet<RepositoryPath>, FilesError> {
26    match GitRepository::discover(root.as_path()) {
27        Err(error) if error.code() == ErrorCode::NotFound => files_below(root),
28        _ => Repository::open(root.as_path())?.visible_files(),
29    }
30}
31
32pub(crate) fn files_below(root: &RepositoryRoot) -> Result<BTreeSet<RepositoryPath>, FilesError> {
33    let mut files = BTreeSet::new();
34    collect(root, root.as_path(), &mut files)?;
35    Ok(files)
36}
37
38fn collect(
39    root: &RepositoryRoot,
40    directory: &Path,
41    files: &mut BTreeSet<RepositoryPath>,
42) -> Result<(), FilesError> {
43    for item in fs::read_dir(directory).at_path(directory)? {
44        let item = item.at_path(directory)?;
45        if item.file_name() == ".git" {
46            continue;
47        }
48        let path = item.path();
49        let metadata = item.metadata().at_path(&path)?;
50        if metadata.is_dir() {
51            collect(root, &path, files)?;
52        } else if metadata.is_file() {
53            files.insert(root.relative(&path)?);
54        }
55    }
56    Ok(())
57}
58
59#[cfg(test)]
60mod tests {
61    use std::fs;
62    use std::os::unix::fs::{PermissionsExt as _, symlink};
63    use std::os::unix::net::UnixListener;
64    use std::path::Path;
65
66    use git2::Repository as GitRepository;
67
68    use super::walk;
69    use crate::RepositoryRoot;
70
71    fn repo() -> tempfile::TempDir {
72        let dir = tempfile::tempdir().expect("tempdir");
73        GitRepository::init(dir.path()).expect("init repository");
74        dir
75    }
76
77    fn add(root: &Path, paths: &[&str]) {
78        let repository = GitRepository::open(root).expect("open repository");
79        let mut index = repository.index().expect("repository index");
80        for path in paths {
81            index.add_path(Path::new(path)).expect("stage path");
82        }
83        index.write().expect("write index");
84    }
85
86    fn write(root: &Path, path: &str, content: &str) {
87        let full = root.join(path);
88        fs::create_dir_all(full.parent().unwrap()).unwrap();
89        fs::write(full, content).unwrap();
90    }
91
92    fn walked(root: &Path) -> Vec<String> {
93        walk(&RepositoryRoot::new(root).unwrap())
94            .unwrap()
95            .iter()
96            .map(ToString::to_string)
97            .collect()
98    }
99
100    #[test]
101    fn tracked_and_untracked_files_are_listed() {
102        let dir = repo();
103        write(dir.path(), "tracked.md", "");
104        write(dir.path(), "docs/new.md", "");
105        add(dir.path(), &["tracked.md"]);
106        assert_eq!(walked(dir.path()), ["docs/new.md", "tracked.md"]);
107    }
108
109    #[test]
110    fn a_gitignored_file_is_not_listed() {
111        let dir = repo();
112        write(dir.path(), ".gitignore", "*.log\n");
113        write(dir.path(), "a.log", "");
114        write(dir.path(), "a.md", "");
115        assert_eq!(walked(dir.path()), [".gitignore", "a.md"]);
116    }
117
118    #[test]
119    fn a_file_in_the_info_exclude_is_not_listed() {
120        let dir = repo();
121        write(dir.path(), ".git/info/exclude", "secret.md\n");
122        write(dir.path(), "secret.md", "");
123        write(dir.path(), "a.md", "");
124        assert_eq!(walked(dir.path()), ["a.md"]);
125    }
126
127    #[test]
128    fn a_file_in_the_global_excludes_is_not_listed() {
129        let dir = repo();
130        let global = tempfile::NamedTempFile::new().unwrap();
131        fs::write(global.path(), "global.md\n").unwrap();
132        GitRepository::open(dir.path())
133            .unwrap()
134            .config()
135            .unwrap()
136            .set_str("core.excludesFile", global.path().to_str().unwrap())
137            .unwrap();
138        write(dir.path(), "global.md", "");
139        write(dir.path(), "a.md", "");
140        assert_eq!(walked(dir.path()), ["a.md"]);
141    }
142
143    #[test]
144    fn symbolic_links_are_not_listed() {
145        let dir = repo();
146        write(dir.path(), "a.md", "");
147        symlink("a.md", dir.path().join("tracked-link")).unwrap();
148        symlink("a.md", dir.path().join("untracked-link")).unwrap();
149        add(dir.path(), &["tracked-link"]);
150        assert_eq!(walked(dir.path()), ["a.md"]);
151    }
152
153    #[test]
154    fn a_tracked_file_matched_by_a_gitignore_is_still_listed() {
155        let dir = repo();
156        write(dir.path(), ".gitignore", "*.log\n");
157        write(dir.path(), "kept.log", "");
158        add(dir.path(), &["kept.log"]);
159        assert_eq!(walked(dir.path()), [".gitignore", "kept.log"]);
160    }
161
162    #[test]
163    fn without_a_repository_every_regular_file_is_listed() {
164        let dir = tempfile::tempdir().unwrap();
165        write(dir.path(), "a.md", "");
166        write(dir.path(), "docs/b.md", "");
167        write(dir.path(), "docs/.git/config", "");
168        write(dir.path(), "sub/.git", "gitdir: elsewhere");
169        write(dir.path(), "sub/c.md", "");
170        symlink("a.md", dir.path().join("link")).unwrap();
171        assert_eq!(walked(dir.path()), ["a.md", "docs/b.md", "sub/c.md"]);
172    }
173
174    #[test]
175    fn without_a_repository_sockets_are_not_listed() {
176        let dir = tempfile::tempdir().unwrap();
177        write(dir.path(), "a.md", "");
178        let _socket = UnixListener::bind(dir.path().join("sock")).unwrap();
179        assert_eq!(walked(dir.path()), ["a.md"]);
180    }
181
182    #[test]
183    fn an_ignored_directory_is_not_entered() {
184        let dir = repo();
185        write(dir.path(), ".gitignore", "target/\n");
186        write(dir.path(), "target/artifact", "");
187        write(dir.path(), "a.md", "");
188        let target = dir.path().join("target");
189        fs::set_permissions(&target, fs::Permissions::from_mode(0o000)).unwrap();
190        let result = walk(&RepositoryRoot::new(dir.path()).unwrap());
191        fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
192        assert_eq!(
193            result
194                .unwrap()
195                .iter()
196                .map(ToString::to_string)
197                .collect::<Vec<_>>(),
198            [".gitignore", "a.md"]
199        );
200    }
201
202    #[test]
203    fn a_root_inside_a_repository_is_rejected() {
204        let dir = repo();
205        write(dir.path(), "docs/a.md", "");
206        let root = RepositoryRoot::new(&dir.path().join("docs")).unwrap();
207        assert!(walk(&root).is_err());
208    }
209}