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