1use std::env;
2use std::ffi::OsString;
3use std::fs;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use tempfile::{NamedTempFile, TempDir};
8use walkdir::WalkDir;
9
10use crate::call;
11use crate::cli::{path_from_bytes, path_to_bytes};
12use crate::errors::{FileError, PruneError};
13
14const EMPTY_BLOB: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
15
16pub fn remove_symlinks(dir: &Path) -> Result<(), FileError> {
17 for entry in WalkDir::new(dir)
18 .min_depth(1)
19 .into_iter()
20 .filter_map(Result::ok)
21 {
22 if entry.file_type().is_symlink() {
23 remove_if_present(entry.path(), fs::remove_file)?;
24 }
25 }
26
27 Ok(())
28}
29
30pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<(), PruneError> {
31 if patterns.is_empty() {
33 return Ok(());
34 }
35 fs::read_dir(repo_path).map_err(file_error("reading", repo_path))?;
37 let repo_path = &std::path::absolute(repo_path).map_err(file_error("resolving", repo_path))?;
38 for file in excluded_files(repo_path, patterns)? {
39 remove_if_present(&repo_path.join(file), fs::remove_file)?;
40 }
41
42 remove_empty_dirs(repo_path)?;
43 Ok(())
44}
45
46fn excluded_files(repo_path: &Path, patterns: &[String]) -> Result<Vec<PathBuf>, PruneError> {
47 let exclusions = exclusions_file(patterns)?;
48 let mut exclude_from = OsString::from("--exclude-from=");
49 exclude_from.push(exclusions.path());
50 let git_dir = empty_git_dir()?;
51 index_every_file(git_dir.path(), repo_path)?;
52
53 let output = call::run_blocking(
56 "ls-files",
57 Command::new("git")
58 .arg("--git-dir")
59 .arg(git_dir.path())
60 .arg("--work-tree")
61 .arg(repo_path)
62 .args(["-c", "core.ignorecase=false"])
64 .args(["ls-files", "-z", "--cached", "--ignored"])
65 .arg(exclude_from)
66 .current_dir(repo_path),
68 )?;
69
70 Ok(output
71 .stdout
72 .split(|byte| *byte == 0)
73 .filter(|file| !file.is_empty())
74 .map(path_from_bytes)
75 .collect())
76}
77
78fn index_every_file(git_dir: &Path, repo_path: &Path) -> Result<(), PruneError> {
81 let mut entries = Vec::new();
82 for entry in WalkDir::new(repo_path)
83 .min_depth(1)
84 .into_iter()
85 .filter_entry(|entry| entry.file_name() != ".git")
86 .filter_map(Result::ok)
87 .filter(|entry| !entry.file_type().is_dir())
88 {
89 if let Ok(path) = entry.path().strip_prefix(repo_path) {
90 entries.extend_from_slice(format!("100644 {EMPTY_BLOB}\t").as_bytes());
91 entries.extend_from_slice(&path_to_bytes(path));
92 entries.push(0);
93 }
94 }
95 let mut file = NamedTempFile::new().map_err(file_error("creating", &env::temp_dir()))?;
96 file.write_all(&entries)
97 .map_err(file_error("writing", file.path()))?;
98 let stdin = file.reopen().map_err(file_error("reading", file.path()))?;
99
100 call::run_blocking(
101 "update-index",
102 Command::new("git")
103 .arg("--git-dir")
104 .arg(git_dir)
105 .args(["update-index", "-z", "--index-info"])
106 .stdin(stdin)
107 .current_dir(git_dir),
108 )?;
109 Ok(())
110}
111
112fn empty_git_dir() -> Result<TempDir, PruneError> {
113 let git_dir = TempDir::new().map_err(file_error("creating", &env::temp_dir()))?;
114 call::run_blocking(
115 "init",
116 Command::new("git")
117 .args(["init", "-q", "--bare"])
118 .arg(git_dir.path())
119 .current_dir(git_dir.path()),
120 )?;
121 Ok(git_dir)
122}
123
124fn exclusions_file(patterns: &[String]) -> Result<NamedTempFile, FileError> {
127 let mut file = NamedTempFile::new().map_err(file_error("creating", &env::temp_dir()))?;
128 for pattern in patterns.iter().filter(|pattern| !pattern.contains('\n')) {
130 writeln!(file, "{pattern}").map_err(file_error("writing", file.path()))?;
131 }
132 Ok(file)
133}
134
135fn remove_empty_dirs(root: &Path) -> Result<(), FileError> {
136 let mut dirs: Vec<_> = Vec::new();
137
138 for entry in WalkDir::new(root) {
139 let entry = entry.map_err(|error| FileError {
140 action: "reading",
141 path: error.path().unwrap_or(root).to_path_buf(),
142 source: error.into(),
143 })?;
144 let path = entry.path();
145
146 let Ok(rel) = path.strip_prefix(root) else {
147 continue;
148 };
149
150 let rel_str = rel.to_string_lossy();
151
152 if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
153 continue;
154 }
155
156 if entry.file_type().is_dir() && path != root {
157 dirs.push(path.to_path_buf());
158 }
159 }
160
161 dirs.sort();
162 dirs.reverse();
163
164 for dir in dirs {
165 let is_empty = match fs::read_dir(&dir) {
166 Ok(mut entries) => entries.next().is_none(),
167 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
168 Err(source) => return Err(file_error("reading", &dir)(source)),
169 };
170 if is_empty {
171 remove_if_present(&dir, fs::remove_dir)?;
172 }
173 }
174
175 Ok(())
176}
177
178fn file_error<'a>(
179 action: &'static str,
180 path: &'a Path,
181) -> impl FnOnce(io::Error) -> FileError + 'a {
182 move |source| FileError {
183 action,
184 path: path.to_path_buf(),
185 source,
186 }
187}
188
189fn remove_if_present<'a>(
191 path: &'a Path,
192 remove: fn(&'a Path) -> io::Result<()>,
193) -> Result<(), FileError> {
194 match remove(path) {
195 Err(source) if source.kind() != io::ErrorKind::NotFound => {
196 Err(file_error("removing", path)(source))
197 }
198 _ => Ok(()),
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::{delete_gitignored_files, remove_symlinks};
205
206 fn indexed_repo(files: &[&str]) -> tempfile::TempDir {
208 let dir = tempfile::tempdir().unwrap();
209 for file in files {
210 let path = dir.path().join(file);
211 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
212 std::fs::write(path, b"").unwrap();
213 }
214 for args in [&["init", "-q"][..], &["add", "-A"]] {
215 let status = std::process::Command::new("git")
216 .arg("-C")
217 .arg(dir.path())
218 .args(args)
219 .status()
220 .unwrap();
221 assert!(status.success());
222 }
223 dir
224 }
225
226 fn exclusions(patterns: &[&str]) -> Vec<String> {
227 patterns
228 .iter()
229 .map(|pattern| (*pattern).to_owned())
230 .collect()
231 }
232
233 #[test]
234 fn a_directory_pattern_deletes_what_is_under_it() {
235 let dir = indexed_repo(&["docs/a.md", "docs/sub/b.md", "keep.py"]);
236 let repo = dir.path();
237
238 delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
239
240 assert!(!repo.join("docs").exists());
241 assert!(repo.join("keep.py").exists());
242 assert!(repo.join(".git/config").exists());
243 }
244
245 #[test]
246 fn a_directory_that_is_not_a_repository_is_pruned_too() {
247 let dir = tempfile::tempdir().unwrap();
248 let root = dir.path();
249 std::fs::create_dir_all(root.join("docs/sub")).unwrap();
250 std::fs::write(root.join("docs/sub/a.md"), b"").unwrap();
251 std::fs::write(root.join("keep.py"), b"").unwrap();
252
253 delete_gitignored_files(root, &exclusions(&["docs"])).unwrap();
254
255 assert!(!root.join("docs").exists());
256 assert!(root.join("keep.py").exists());
257 }
258
259 #[test]
260 fn a_file_in_a_nested_repository_is_deleted_too() {
261 let dir = indexed_repo(&["top.log", "inner/a.log", "inner/keep.py"]);
262 let repo = dir.path();
263 let status = std::process::Command::new("git")
264 .args(["init", "-q"])
265 .arg(repo.join("inner"))
266 .status()
267 .unwrap();
268 assert!(status.success());
269
270 delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
271
272 assert!(!repo.join("top.log").exists());
273 assert!(!repo.join("inner/a.log").exists());
274 assert!(repo.join("inner/keep.py").exists());
275 assert!(repo.join("inner/.git/HEAD").exists());
276 }
277
278 #[test]
279 fn the_gitignore_of_the_tree_adds_no_rules() {
280 let dir = indexed_repo(&["keep.py", "a.log"]);
281 let repo = dir.path();
282 std::fs::write(repo.join(".gitignore"), b"*.py\n").unwrap();
283
284 delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
285
286 assert!(repo.join("keep.py").exists());
287 assert!(!repo.join("a.log").exists());
288 }
289
290 #[test]
291 fn no_exclusions_keep_every_file() {
292 let dir = indexed_repo(&["keep.py"]);
293
294 delete_gitignored_files(dir.path(), &[]).unwrap();
295
296 assert!(dir.path().join("keep.py").exists());
297 }
298
299 #[test]
300 fn an_exclusion_that_matches_nothing_does_not_disable_the_others() {
301 let dir = indexed_repo(&["docs/a.md", "keep.py"]);
302 let repo = dir.path();
303
304 delete_gitignored_files(repo, &exclusions(&["docs", "[z-a]"])).unwrap();
305
306 assert!(!repo.join("docs/a.md").exists());
307 assert!(repo.join("keep.py").exists());
308 }
309
310 #[test]
311 fn an_unclosed_bracket_matches_nothing() {
312 let dir = indexed_repo(&["[main", "]"]);
313 let repo = dir.path();
314
315 delete_gitignored_files(repo, &exclusions(&["[main", "[]]"])).unwrap();
316
317 assert!(repo.join("[main").exists());
318 assert!(!repo.join("]").exists());
319 }
320
321 #[test]
322 fn braces_match_literally() {
323 let dir = indexed_repo(&["a/x", "{a,b}/x", "a}b", "cq"]);
324 let repo = dir.path();
325
326 delete_gitignored_files(repo, &exclusions(&["{a,b}/x", "a}b", "c[a-{]"])).unwrap();
327
328 assert!(repo.join("a/x").exists());
329 assert!(!repo.join("{a,b}/x").exists());
330 assert!(!repo.join("a}b").exists());
331 assert!(!repo.join("cq").exists());
332 }
333
334 #[test]
335 fn a_backslash_takes_the_next_character_literally() {
336 let dir = indexed_repo(&["a[{b}", "{a}", "ab"]);
337 let repo = dir.path();
338
339 delete_gitignored_files(repo, &exclusions(&[r"a\[{b}", r"\{a}"])).unwrap();
340
341 assert!(!repo.join("a[{b}").exists());
342 assert!(!repo.join("{a}").exists());
343 assert!(repo.join("ab").exists());
344 }
345
346 #[test]
347 fn a_named_class_matches_its_characters() {
348 let dir = indexed_repo(&["f5", "fx"]);
349 let repo = dir.path();
350
351 delete_gitignored_files(repo, &exclusions(&["f[[:digit:]]"])).unwrap();
352
353 assert!(!repo.join("f5").exists());
354 assert!(repo.join("fx").exists());
355 }
356
357 #[test]
358 fn an_exclusion_keeps_its_case_in_a_case_insensitive_clone() {
359 let dir = indexed_repo(&["README.md"]);
360 let repo = dir.path();
361 let status = std::process::Command::new("git")
362 .arg("-C")
363 .arg(repo)
364 .args(["config", "core.ignorecase", "true"])
365 .status()
366 .unwrap();
367 assert!(status.success());
368
369 delete_gitignored_files(repo, &exclusions(&["readme.md"])).unwrap();
370
371 assert!(repo.join("README.md").exists());
372 }
373
374 #[test]
375 fn an_exclusion_reads_as_a_gitignore_line() {
376 let dir = indexed_repo(&["#a", "b", "c"]);
377 let repo = dir.path();
378
379 delete_gitignored_files(repo, &exclusions(&["#a", "b ", "c"])).unwrap();
380
381 assert!(repo.join("#a").exists());
382 assert!(!repo.join("b").exists());
383 assert!(!repo.join("c").exists());
384 }
385
386 #[test]
387 fn an_untracked_file_is_deleted_too() {
388 let dir = indexed_repo(&["docs/a.md"]);
389 let repo = dir.path();
390 std::fs::write(repo.join("docs/new.md"), b"").unwrap();
391
392 delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
393
394 assert!(!repo.join("docs").exists());
395 }
396
397 #[test]
398 fn an_exclusion_with_a_line_break_adds_no_rules() {
399 let dir = indexed_repo(&["a", "b"]);
400 let repo = dir.path();
401
402 delete_gitignored_files(repo, &exclusions(&["a\nb"])).unwrap();
403
404 assert!(repo.join("a").exists());
405 assert!(repo.join("b").exists());
406 }
407
408 #[test]
409 #[cfg(unix)]
410 fn an_unreadable_directory_does_not_stop_the_removal() {
411 use std::os::unix::fs::PermissionsExt;
412
413 let dir = tempfile::tempdir().unwrap();
414 let target = dir.path().join("target.txt");
415 std::fs::write(&target, b"content").unwrap();
416
417 let blocked = dir.path().join("b_blocked");
418 std::fs::create_dir(&blocked).unwrap();
419 let late = dir.path().join("c_late.lnk");
420 std::os::unix::fs::symlink(&target, &late).unwrap();
421 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
422
423 remove_symlinks(dir.path()).unwrap();
424
425 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
426 assert!(!late.is_symlink());
427 assert!(target.exists());
428 }
429
430 #[test]
431 #[cfg(unix)]
432 fn a_symlink_that_cannot_be_removed_is_reported() {
433 use std::os::unix::fs::PermissionsExt;
434
435 let dir = tempfile::tempdir().unwrap();
436 let sealed = dir.path().join("sealed");
437 std::fs::create_dir(&sealed).unwrap();
438 let link = sealed.join("link.lnk");
439 std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
440 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
441
442 if std::fs::write(sealed.join("probe"), b"").is_ok() {
445 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
446 return;
447 }
448
449 let result = remove_symlinks(dir.path());
450
451 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
452 assert_eq!(result.unwrap_err().path, link);
453 assert!(link.is_symlink());
454 }
455
456 #[test]
457 #[cfg(unix)]
458 fn the_directory_itself_is_never_removed() {
459 let dir = tempfile::tempdir().unwrap();
460 let real = dir.path().join("real");
461 std::fs::create_dir(&real).unwrap();
462 let alias = dir.path().join("alias");
463 std::os::unix::fs::symlink(&real, &alias).unwrap();
464
465 remove_symlinks(&alias).unwrap();
466
467 assert!(alias.is_symlink());
468 }
469}