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
12pub 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}