1use anyhow::{bail, Context, Result};
2use std::ffi::OsString;
3use std::fs;
4use std::io::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 fs::remove_file(entry.path())?;
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 for file in excluded_files(repo_path, patterns)? {
34 let _ = fs::remove_file(repo_path.join(file));
35 }
36
37 remove_empty_dirs(repo_path)?;
38 Ok(())
39}
40
41fn excluded_files(repo_path: &Path, patterns: &[String]) -> Result<Vec<PathBuf>> {
42 let exclusions = exclusions_file(patterns)?;
43 let mut exclude_from = OsString::from("--exclude-from=");
44 exclude_from.push(exclusions.path());
45 let git_dir = empty_git_dir()?;
46 index_every_file(git_dir.path(), repo_path)?;
47
48 let output = Command::new("git")
51 .arg("--git-dir")
52 .arg(git_dir.path())
53 .arg("--work-tree")
54 .arg(repo_path)
55 .args(["-c", "core.ignorecase=false"])
57 .args(["ls-files", "-z", "--cached", "--ignored"])
58 .arg(exclude_from)
59 .output()
60 .context("running git ls-files")?;
61 if !output.status.success() {
62 bail!(
63 "git ls-files failed: {}",
64 String::from_utf8_lossy(&output.stderr)
65 );
66 }
67
68 Ok(output
69 .stdout
70 .split(|byte| *byte == 0)
71 .filter(|file| !file.is_empty())
72 .map(path_from_bytes)
73 .collect())
74}
75
76fn index_every_file(git_dir: &Path, repo_path: &Path) -> Result<()> {
79 let mut entries = Vec::new();
80 for entry in WalkDir::new(repo_path)
81 .min_depth(1)
82 .into_iter()
83 .filter_entry(|entry| entry.file_name() != ".git")
84 .filter_map(Result::ok)
85 .filter(|entry| !entry.file_type().is_dir())
86 {
87 if let Ok(path) = entry.path().strip_prefix(repo_path) {
88 entries.extend_from_slice(format!("100644 {EMPTY_BLOB}\t").as_bytes());
89 entries.extend_from_slice(&path_to_bytes(path));
90 entries.push(0);
91 }
92 }
93 let mut file = NamedTempFile::new().context("creating the index entries")?;
94 file.write_all(&entries)
95 .context("writing the index entries")?;
96
97 let output = Command::new("git")
98 .arg("--git-dir")
99 .arg(git_dir)
100 .args(["update-index", "-z", "--index-info"])
101 .stdin(file.reopen().context("reading the index entries")?)
102 .output()
103 .context("running git update-index")?;
104 if !output.status.success() {
105 bail!(
106 "git update-index failed: {}",
107 String::from_utf8_lossy(&output.stderr)
108 );
109 }
110 Ok(())
111}
112
113fn empty_git_dir() -> Result<TempDir> {
114 let git_dir = TempDir::new().context("creating the git dir")?;
115 let output = Command::new("git")
116 .args(["init", "-q", "--bare"])
117 .arg(git_dir.path())
118 .output()
119 .context("running git init")?;
120 if !output.status.success() {
121 bail!(
122 "git init failed: {}",
123 String::from_utf8_lossy(&output.stderr)
124 );
125 }
126 Ok(git_dir)
127}
128
129fn exclusions_file(patterns: &[String]) -> Result<NamedTempFile> {
132 let mut file = NamedTempFile::new().context("creating the exclusions file")?;
133 for pattern in patterns.iter().filter(|pattern| !pattern.contains('\n')) {
135 writeln!(file, "{pattern}").context("writing the exclusions file")?;
136 }
137 Ok(file)
138}
139
140pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
141 fs::create_dir_all(dst)?;
142 for entry in fs::read_dir(src)? {
143 let entry = entry?;
144 let ty = entry.file_type()?;
145 let dst_path = dst.join(entry.file_name());
146 if ty.is_dir() {
147 copy_dir_all(&entry.path(), &dst_path)?;
148 } else {
149 fs::copy(entry.path(), &dst_path)?;
150 copy_modified(&entry.path(), &dst_path);
151 }
152 }
153 Ok(())
154}
155
156fn copy_modified(src: &Path, dst: &Path) {
157 let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
158 return;
159 };
160 if let Ok(file) = fs::File::open(dst) {
161 let _ = file.set_modified(modified);
162 }
163}
164
165fn remove_empty_dirs(root: &Path) -> Result<()> {
166 let mut dirs: Vec<_> = Vec::new();
167
168 for entry in WalkDir::new(root) {
169 let entry = entry?;
170 let path = entry.path();
171
172 let Ok(rel) = path.strip_prefix(root) else {
173 continue;
174 };
175
176 let rel_str = rel.to_string_lossy();
177
178 if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
179 continue;
180 }
181
182 if entry.file_type().is_dir() && path != root {
183 dirs.push(path.to_path_buf());
184 }
185 }
186
187 dirs.sort();
188 dirs.reverse();
189
190 for dir in dirs {
191 if let Ok(entries) = fs::read_dir(&dir) {
192 if entries.count() == 0 {
193 let _ = fs::remove_dir(&dir);
194 }
195 }
196 }
197
198 Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203 use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
204 use std::time::{Duration, SystemTime};
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 fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
410 let dir = tempfile::tempdir().unwrap();
411 let src = dir.path().join("src/objects");
412 std::fs::create_dir_all(&src).unwrap();
413 let object = src.join("a1b2c3");
414 std::fs::write(&object, b"blob").unwrap();
415
416 let modified = SystemTime::UNIX_EPOCH
417 .checked_add(Duration::from_secs(1_700_000_000))
418 .unwrap();
419 std::fs::File::open(&object)
420 .unwrap()
421 .set_modified(modified)
422 .unwrap();
423 #[cfg(unix)]
424 {
425 use std::os::unix::fs::PermissionsExt;
426 std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
427 }
428
429 let dst = dir.path().join("dst");
430 copy_dir_all(&dir.path().join("src"), &dst).unwrap();
431
432 let copied = dst.join("objects/a1b2c3");
433 let meta = std::fs::metadata(&copied).unwrap();
434 assert_eq!(meta.modified().unwrap(), modified);
435 #[cfg(unix)]
436 {
437 use std::os::unix::fs::PermissionsExt;
438 assert_eq!(meta.permissions().mode() & 0o777, 0o444);
439 }
440 }
441
442 #[test]
443 #[cfg(unix)]
444 fn an_unreadable_directory_does_not_stop_the_removal() {
445 use std::os::unix::fs::PermissionsExt;
446
447 let dir = tempfile::tempdir().unwrap();
448 let target = dir.path().join("target.txt");
449 std::fs::write(&target, b"content").unwrap();
450
451 let blocked = dir.path().join("b_blocked");
452 std::fs::create_dir(&blocked).unwrap();
453 let late = dir.path().join("c_late.lnk");
454 std::os::unix::fs::symlink(&target, &late).unwrap();
455 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
456
457 remove_symlinks(dir.path()).unwrap();
458
459 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
460 assert!(!late.is_symlink());
461 assert!(target.exists());
462 }
463
464 #[test]
465 #[cfg(unix)]
466 fn a_symlink_that_cannot_be_removed_is_reported() {
467 use std::os::unix::fs::PermissionsExt;
468
469 let dir = tempfile::tempdir().unwrap();
470 let sealed = dir.path().join("sealed");
471 std::fs::create_dir(&sealed).unwrap();
472 let link = sealed.join("link.lnk");
473 std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
474 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
475
476 if std::fs::write(sealed.join("probe"), b"").is_ok() {
479 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
480 return;
481 }
482
483 let result = remove_symlinks(dir.path());
484
485 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
486 assert!(result.is_err());
487 assert!(link.is_symlink());
488 }
489
490 #[test]
491 #[cfg(unix)]
492 fn the_directory_itself_is_never_removed() {
493 let dir = tempfile::tempdir().unwrap();
494 let real = dir.path().join("real");
495 std::fs::create_dir(&real).unwrap();
496 let alias = dir.path().join("alias");
497 std::os::unix::fs::symlink(&real, &alias).unwrap();
498
499 remove_symlinks(&alias).unwrap();
500
501 assert!(alias.is_symlink());
502 }
503}