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