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)
37 .with_context(|| format!("resolving {}", repo_path.display()))?;
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>> {
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 = Command::new("git")
56 .arg("--git-dir")
57 .arg(git_dir.path())
58 .arg("--work-tree")
59 .arg(repo_path)
60 .args(["-c", "core.ignorecase=false"])
62 .args(["ls-files", "-z", "--cached", "--ignored"])
63 .arg(exclude_from)
64 .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
148pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
149 fs::create_dir_all(dst)?;
150 for entry in fs::read_dir(src)? {
151 let entry = entry?;
152 let ty = entry.file_type()?;
153 let dst_path = dst.join(entry.file_name());
154 if ty.is_dir() {
155 copy_dir_all(&entry.path(), &dst_path)?;
156 } else {
157 fs::copy(entry.path(), &dst_path)?;
158 copy_modified(&entry.path(), &dst_path);
159 }
160 }
161 Ok(())
162}
163
164fn copy_modified(src: &Path, dst: &Path) {
165 let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
166 return;
167 };
168 if let Ok(file) = fs::File::open(dst) {
169 let _ = file.set_modified(modified);
170 }
171}
172
173fn remove_empty_dirs(root: &Path) -> Result<()> {
174 let mut dirs: Vec<_> = Vec::new();
175
176 for entry in WalkDir::new(root) {
177 let entry = entry?;
178 let path = entry.path();
179
180 let Ok(rel) = path.strip_prefix(root) else {
181 continue;
182 };
183
184 let rel_str = rel.to_string_lossy();
185
186 if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
187 continue;
188 }
189
190 if entry.file_type().is_dir() && path != root {
191 dirs.push(path.to_path_buf());
192 }
193 }
194
195 dirs.sort();
196 dirs.reverse();
197
198 for dir in dirs {
199 let is_empty = match fs::read_dir(&dir) {
200 Ok(mut entries) => entries.next().is_none(),
201 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
202 Err(error) => {
203 return Err(error).with_context(|| format!("reading {}", dir.display()));
204 }
205 };
206 if is_empty {
207 remove_if_present(&dir, fs::remove_dir)?;
208 }
209 }
210
211 Ok(())
212}
213
214fn remove_if_present<'a>(path: &'a Path, remove: fn(&'a Path) -> io::Result<()>) -> Result<()> {
216 match remove(path) {
217 Err(error) if error.kind() != io::ErrorKind::NotFound => {
218 Err(error).with_context(|| format!("removing {}", path.display()))
219 }
220 _ => Ok(()),
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
227 use std::time::{Duration, SystemTime};
228
229 fn indexed_repo(files: &[&str]) -> tempfile::TempDir {
231 let dir = tempfile::tempdir().unwrap();
232 for file in files {
233 let path = dir.path().join(file);
234 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
235 std::fs::write(path, b"").unwrap();
236 }
237 for args in [&["init", "-q"][..], &["add", "-A"]] {
238 let status = std::process::Command::new("git")
239 .arg("-C")
240 .arg(dir.path())
241 .args(args)
242 .status()
243 .unwrap();
244 assert!(status.success());
245 }
246 dir
247 }
248
249 fn exclusions(patterns: &[&str]) -> Vec<String> {
250 patterns
251 .iter()
252 .map(|pattern| (*pattern).to_owned())
253 .collect()
254 }
255
256 #[test]
257 fn a_directory_pattern_deletes_what_is_under_it() {
258 let dir = indexed_repo(&["docs/a.md", "docs/sub/b.md", "keep.py"]);
259 let repo = dir.path();
260
261 delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
262
263 assert!(!repo.join("docs").exists());
264 assert!(repo.join("keep.py").exists());
265 assert!(repo.join(".git/config").exists());
266 }
267
268 #[test]
269 fn a_directory_that_is_not_a_repository_is_pruned_too() {
270 let dir = tempfile::tempdir().unwrap();
271 let root = dir.path();
272 std::fs::create_dir_all(root.join("docs/sub")).unwrap();
273 std::fs::write(root.join("docs/sub/a.md"), b"").unwrap();
274 std::fs::write(root.join("keep.py"), b"").unwrap();
275
276 delete_gitignored_files(root, &exclusions(&["docs"])).unwrap();
277
278 assert!(!root.join("docs").exists());
279 assert!(root.join("keep.py").exists());
280 }
281
282 #[test]
283 fn a_file_in_a_nested_repository_is_deleted_too() {
284 let dir = indexed_repo(&["top.log", "inner/a.log", "inner/keep.py"]);
285 let repo = dir.path();
286 let status = std::process::Command::new("git")
287 .args(["init", "-q"])
288 .arg(repo.join("inner"))
289 .status()
290 .unwrap();
291 assert!(status.success());
292
293 delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
294
295 assert!(!repo.join("top.log").exists());
296 assert!(!repo.join("inner/a.log").exists());
297 assert!(repo.join("inner/keep.py").exists());
298 assert!(repo.join("inner/.git/HEAD").exists());
299 }
300
301 #[test]
302 fn the_gitignore_of_the_tree_adds_no_rules() {
303 let dir = indexed_repo(&["keep.py", "a.log"]);
304 let repo = dir.path();
305 std::fs::write(repo.join(".gitignore"), b"*.py\n").unwrap();
306
307 delete_gitignored_files(repo, &exclusions(&["*.log"])).unwrap();
308
309 assert!(repo.join("keep.py").exists());
310 assert!(!repo.join("a.log").exists());
311 }
312
313 #[test]
314 fn no_exclusions_keep_every_file() {
315 let dir = indexed_repo(&["keep.py"]);
316
317 delete_gitignored_files(dir.path(), &[]).unwrap();
318
319 assert!(dir.path().join("keep.py").exists());
320 }
321
322 #[test]
323 fn an_exclusion_that_matches_nothing_does_not_disable_the_others() {
324 let dir = indexed_repo(&["docs/a.md", "keep.py"]);
325 let repo = dir.path();
326
327 delete_gitignored_files(repo, &exclusions(&["docs", "[z-a]"])).unwrap();
328
329 assert!(!repo.join("docs/a.md").exists());
330 assert!(repo.join("keep.py").exists());
331 }
332
333 #[test]
334 fn an_unclosed_bracket_matches_nothing() {
335 let dir = indexed_repo(&["[main", "]"]);
336 let repo = dir.path();
337
338 delete_gitignored_files(repo, &exclusions(&["[main", "[]]"])).unwrap();
339
340 assert!(repo.join("[main").exists());
341 assert!(!repo.join("]").exists());
342 }
343
344 #[test]
345 fn braces_match_literally() {
346 let dir = indexed_repo(&["a/x", "{a,b}/x", "a}b", "cq"]);
347 let repo = dir.path();
348
349 delete_gitignored_files(repo, &exclusions(&["{a,b}/x", "a}b", "c[a-{]"])).unwrap();
350
351 assert!(repo.join("a/x").exists());
352 assert!(!repo.join("{a,b}/x").exists());
353 assert!(!repo.join("a}b").exists());
354 assert!(!repo.join("cq").exists());
355 }
356
357 #[test]
358 fn a_backslash_takes_the_next_character_literally() {
359 let dir = indexed_repo(&["a[{b}", "{a}", "ab"]);
360 let repo = dir.path();
361
362 delete_gitignored_files(repo, &exclusions(&[r"a\[{b}", r"\{a}"])).unwrap();
363
364 assert!(!repo.join("a[{b}").exists());
365 assert!(!repo.join("{a}").exists());
366 assert!(repo.join("ab").exists());
367 }
368
369 #[test]
370 fn a_named_class_matches_its_characters() {
371 let dir = indexed_repo(&["f5", "fx"]);
372 let repo = dir.path();
373
374 delete_gitignored_files(repo, &exclusions(&["f[[:digit:]]"])).unwrap();
375
376 assert!(!repo.join("f5").exists());
377 assert!(repo.join("fx").exists());
378 }
379
380 #[test]
381 fn an_exclusion_keeps_its_case_in_a_case_insensitive_clone() {
382 let dir = indexed_repo(&["README.md"]);
383 let repo = dir.path();
384 let status = std::process::Command::new("git")
385 .arg("-C")
386 .arg(repo)
387 .args(["config", "core.ignorecase", "true"])
388 .status()
389 .unwrap();
390 assert!(status.success());
391
392 delete_gitignored_files(repo, &exclusions(&["readme.md"])).unwrap();
393
394 assert!(repo.join("README.md").exists());
395 }
396
397 #[test]
398 fn an_exclusion_reads_as_a_gitignore_line() {
399 let dir = indexed_repo(&["#a", "b", "c"]);
400 let repo = dir.path();
401
402 delete_gitignored_files(repo, &exclusions(&["#a", "b ", "c"])).unwrap();
403
404 assert!(repo.join("#a").exists());
405 assert!(!repo.join("b").exists());
406 assert!(!repo.join("c").exists());
407 }
408
409 #[test]
410 fn an_untracked_file_is_deleted_too() {
411 let dir = indexed_repo(&["docs/a.md"]);
412 let repo = dir.path();
413 std::fs::write(repo.join("docs/new.md"), b"").unwrap();
414
415 delete_gitignored_files(repo, &exclusions(&["docs"])).unwrap();
416
417 assert!(!repo.join("docs").exists());
418 }
419
420 #[test]
421 fn an_exclusion_with_a_line_break_adds_no_rules() {
422 let dir = indexed_repo(&["a", "b"]);
423 let repo = dir.path();
424
425 delete_gitignored_files(repo, &exclusions(&["a\nb"])).unwrap();
426
427 assert!(repo.join("a").exists());
428 assert!(repo.join("b").exists());
429 }
430
431 #[test]
432 fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
433 let dir = tempfile::tempdir().unwrap();
434 let src = dir.path().join("src/objects");
435 std::fs::create_dir_all(&src).unwrap();
436 let object = src.join("a1b2c3");
437 std::fs::write(&object, b"blob").unwrap();
438
439 let modified = SystemTime::UNIX_EPOCH
440 .checked_add(Duration::from_secs(1_700_000_000))
441 .unwrap();
442 std::fs::File::open(&object)
443 .unwrap()
444 .set_modified(modified)
445 .unwrap();
446 #[cfg(unix)]
447 {
448 use std::os::unix::fs::PermissionsExt;
449 std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
450 }
451
452 let dst = dir.path().join("dst");
453 copy_dir_all(&dir.path().join("src"), &dst).unwrap();
454
455 let copied = dst.join("objects/a1b2c3");
456 let meta = std::fs::metadata(&copied).unwrap();
457 assert_eq!(meta.modified().unwrap(), modified);
458 #[cfg(unix)]
459 {
460 use std::os::unix::fs::PermissionsExt;
461 assert_eq!(meta.permissions().mode() & 0o777, 0o444);
462 }
463 }
464
465 #[test]
466 #[cfg(unix)]
467 fn an_unreadable_directory_does_not_stop_the_removal() {
468 use std::os::unix::fs::PermissionsExt;
469
470 let dir = tempfile::tempdir().unwrap();
471 let target = dir.path().join("target.txt");
472 std::fs::write(&target, b"content").unwrap();
473
474 let blocked = dir.path().join("b_blocked");
475 std::fs::create_dir(&blocked).unwrap();
476 let late = dir.path().join("c_late.lnk");
477 std::os::unix::fs::symlink(&target, &late).unwrap();
478 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
479
480 remove_symlinks(dir.path()).unwrap();
481
482 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
483 assert!(!late.is_symlink());
484 assert!(target.exists());
485 }
486
487 #[test]
488 #[cfg(unix)]
489 fn a_symlink_that_cannot_be_removed_is_reported() {
490 use std::os::unix::fs::PermissionsExt;
491
492 let dir = tempfile::tempdir().unwrap();
493 let sealed = dir.path().join("sealed");
494 std::fs::create_dir(&sealed).unwrap();
495 let link = sealed.join("link.lnk");
496 std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
497 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
498
499 if std::fs::write(sealed.join("probe"), b"").is_ok() {
502 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
503 return;
504 }
505
506 let result = remove_symlinks(dir.path());
507
508 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
509 assert!(result.is_err());
510 assert!(link.is_symlink());
511 }
512
513 #[test]
514 #[cfg(unix)]
515 fn the_directory_itself_is_never_removed() {
516 let dir = tempfile::tempdir().unwrap();
517 let real = dir.path().join("real");
518 std::fs::create_dir(&real).unwrap();
519 let alias = dir.path().join("alias");
520 std::os::unix::fs::symlink(&real, &alias).unwrap();
521
522 remove_symlinks(&alias).unwrap();
523
524 assert!(alias.is_symlink());
525 }
526}