1use std::io::prelude::*;
2
3use anyhow::Result;
4use clap::Parser;
5use rayon::prelude::*;
6use yansi::Paint;
7
8use crate::{cmd, errors, model, model::IndexSet, path};
9
10#[derive(Parser, Clone, Debug)]
12#[command(author, about, long_about)]
13pub struct PruneOptions {
14 #[arg(
16 long = "jobs",
17 short = 'j',
18 num_args = 0..=1,
19 default_value_t = 0,
20 default_missing_value = "0",
21 value_name = "JOBS",
22 )]
23 num_jobs: usize,
24 #[arg(long, short = 'd', default_value_t = -1)]
26 max_depth: isize,
27 #[arg(long, default_value_t = -1)]
29 min_depth: isize,
30 #[arg(long, default_value_t = -1)]
32 exact_depth: isize,
33 #[arg(long)]
35 no_prompt: bool,
36 #[arg(long = "rm")]
38 remove: bool,
39 paths: Vec<String>,
41}
42
43pub fn main(app_context: &model::ApplicationContext, options: &mut PruneOptions) -> Result<()> {
45 let config = app_context.get_root_config_mut();
46
47 if options.num_jobs < 3 {
51 options.num_jobs = 3;
52 }
53
54 if options.max_depth >= 0 && options.max_depth < options.min_depth {
56 println!("error: --max-depth cannot be less than --min-depth");
57 std::process::exit(errors::EX_USAGE);
58 }
59
60 if options.exact_depth >= 0 {
62 if options.min_depth >= 0 || options.max_depth >= 0 {
63 println!("error: --exact-depth cannot be used with --min-depth and --max-depth");
64 std::process::exit(errors::EX_USAGE);
65 }
66 options.min_depth = options.exact_depth;
67 options.max_depth = options.exact_depth;
68 }
69
70 let exit_status = prune(config, options, &options.paths)?;
71
72 errors::exit_status_into_result(exit_status)
74}
75
76enum PathBufMessage {
80 Path(std::path::PathBuf),
81 Finished,
82}
83
84struct TraverseFilesystem<'a> {
87 min_depth: isize,
88 max_depth: isize,
89 send_repo_path: crossbeam::channel::Sender<PathBufMessage>,
90 root_path: std::path::PathBuf,
91 path_filters: &'a Vec<std::path::PathBuf>,
92 configured_tree_paths: &'a IndexSet<std::path::PathBuf>,
93}
94
95impl TraverseFilesystem<'_> {
96 fn traverse(&self) {
98 self.traverse_toplevel(&self.root_path).unwrap_or(());
99 self.send_repo_path
100 .send(PathBufMessage::Finished)
101 .unwrap_or(());
102 }
103
104 fn traverse_toplevel(&self, pathbuf: &std::path::PathBuf) -> std::io::Result<()> {
108 let current_depth: isize = 0;
109 let entries: Vec<_> = std::fs::read_dir(pathbuf)?.collect();
111 entries.par_iter().for_each(|entry_result| {
112 if let Ok(entry) = entry_result {
113 let path = entry.path();
114 if let Some(path_canon) = self.validate_entry_for_traversal(&path) {
115 self.traverse_subdir(&path_canon, current_depth)
116 .unwrap_or(());
117 }
118 }
119 });
120
121 Ok(())
122 }
123
124 fn traverse_subdir(
126 &self,
127 pathbuf: &std::path::PathBuf,
128 current_depth: isize,
129 ) -> std::io::Result<()> {
130 let mut git_dir = pathbuf.to_path_buf();
132 git_dir.push(".git");
133
134 if git_dir.exists() {
135 if is_within_bounds(current_depth, self.min_depth, self.max_depth) {
136 self.send_repo_path
137 .send(PathBufMessage::Path(pathbuf.to_path_buf()))
138 .unwrap_or(());
139 }
140 return Ok(());
141 }
142
143 if let Some(extension) = pathbuf.extension() {
145 if extension == "git" {
146 if is_within_bounds(current_depth, self.min_depth, self.max_depth) {
147 self.send_repo_path
148 .send(PathBufMessage::Path(pathbuf.to_path_buf()))
149 .unwrap_or(());
150 }
151 return Ok(());
152 }
153 }
154
155 let entries: Vec<_> = std::fs::read_dir(pathbuf)?.collect();
157 entries.par_iter().for_each(|entry_result| {
158 if let Ok(entry) = entry_result {
159 let path = entry.path();
160 if let Some(path_canon) = self.validate_entry_for_traversal(&path) {
161 if is_within_max_bounds(current_depth, self.max_depth) {
162 self.traverse_subdir(&path_canon, current_depth + 1)
163 .unwrap_or(());
164 }
165 }
166 }
167 });
168
169 Ok(())
170 }
171
172 fn validate_entry_for_traversal(&self, path: &std::path::Path) -> Option<std::path::PathBuf> {
174 if path.is_dir()
175 && !path.is_symlink()
176 && match path.file_name() {
177 Some(basename) => basename != ".git",
179 None => false,
180 }
181 {
182 if let Ok(path_canon) = path::canonicalize(path) {
183 if !self.configured_tree_paths.contains(&path_canon) && !self.is_filtered_path(path)
184 {
185 return Some(path_canon);
186 }
187 }
188 }
189 None
190 }
191
192 fn is_filtered_path(&self, path: &std::path::Path) -> bool {
194 if self.path_filters.is_empty() {
196 return false;
197 }
198
199 for path_filter in self.path_filters {
201 if path.starts_with(path_filter) || path_filter.starts_with(path) {
202 return false;
203 }
204 }
205
206 true
208 }
209}
210
211fn is_within_bounds(value: isize, min_depth: isize, max_depth: isize) -> bool {
214 value >= min_depth && is_within_max_bounds(value, max_depth)
215}
216
217fn is_within_max_bounds(value: isize, max_depth: isize) -> bool {
220 max_depth == -1 || value <= max_depth
221}
222
223struct RemovePaths {
226 recv_remove_path: crossbeam::channel::Receiver<PathBufMessage>,
228 send_finished_path: crossbeam::channel::Sender<PathBufMessage>,
231 dry_run: bool,
233}
234
235impl RemovePaths {
236 fn remove_paths(&self, remove_scope: &rayon::ScopeFifo<'_>) {
238 loop {
239 match self.recv_remove_path.recv() {
240 Ok(PathBufMessage::Path(pathbuf)) => {
241 if !self.dry_run {
243 let pathbuf = pathbuf.to_path_buf();
244 remove_scope.spawn_fifo(move |_| {
245 rm_rf::ensure_removed(&pathbuf).unwrap_or(());
246
247 let mut parent_option = pathbuf.parent();
249 while let Some(parent_pathbuf) = parent_option {
250 if !parent_pathbuf.exists() {
251 break;
252 }
253 if std::fs::remove_dir(parent_pathbuf).is_err() {
254 break;
255 }
256 parent_option = parent_pathbuf.parent();
257 }
258 });
259 }
260 self.send_finished_path
261 .send(PathBufMessage::Path(pathbuf))
262 .unwrap_or(());
263 }
264 Ok(PathBufMessage::Finished) | Err(_) => {
265 self.send_finished_path
266 .send(PathBufMessage::Finished)
267 .unwrap_or(());
268 return;
269 }
270 }
271 }
272 }
273}
274
275enum PromptResponse {
277 All, Delete, Skip, Quit, }
282
283fn prompt_for_deletion(pathbuf: &dyn AsRef<std::path::Path>) -> PromptResponse {
285 let stdin = std::io::stdin();
286 let mut stdout = std::io::stdout();
287 let answer;
288
289 loop {
290 let path_string = pathbuf.as_ref().to_string_lossy();
291 let path_basename = match pathbuf.as_ref().file_name() {
292 Some(stem) => stem.to_string_lossy(),
293 None => continue,
294 };
295
296 println!();
297 println!("{} {}", "#".cyan(), path_string.blue().bold());
299 println!(
301 "{}",
302 format!("Delete the \"{path_basename}\" repository?").yellow()
303 );
304 println!(
306 "{}: \"{}\" deletes \"{}\" and {} subsequent repositories!",
307 "WARNING".red().bold(),
308 "all".yellow(),
309 path_basename,
310 "ALL".red().bold(),
311 );
312 print!(
314 "Choices: {}, {}, {}, {} [{},{},{},{}]? ",
315 "yes".blue(),
316 "no".blue(),
317 "all".yellow(),
318 "quit".green(),
319 "y".blue(),
320 "n".blue(),
321 "all".yellow(),
322 "q".green(),
323 );
324
325 stdout.flush().unwrap_or(());
326
327 let mut buffer = String::new();
328 if stdin.read_line(&mut buffer).is_ok() {
329 match buffer.trim().to_lowercase().as_str() {
330 "all" => {
332 answer = PromptResponse::All;
333 println!();
334 break;
335 }
336 "y" | "yes" => {
337 answer = PromptResponse::Delete;
338 break;
339 }
340 "n" | "no" | "s" | "skip" => {
341 answer = PromptResponse::Skip;
342 break;
343 }
344 "q" | "quit" => {
345 answer = PromptResponse::Quit;
346 println!();
347 break;
348 }
349 _ => {
350 println!();
351 }
352 }
353 }
354 }
355
356 answer
357}
358
359struct PromptUser {
360 recv_repo_path: crossbeam::channel::Receiver<PathBufMessage>,
361 send_remove_path: crossbeam::channel::Sender<PathBufMessage>,
362 recv_finished_path: crossbeam::channel::Receiver<PathBufMessage>,
363 no_prompt: bool,
364 quit: bool,
365}
366
367impl PromptUser {
368 fn prompt_for_deletion(&mut self) {
369 loop {
370 match self.recv_repo_path.recv() {
371 Ok(PathBufMessage::Path(pathbuf)) => {
372 if !self.quit {
373 self.prompt_pathbuf_for_deletion(&pathbuf);
374 }
375 }
376 Ok(PathBufMessage::Finished) | Err(_) => {
377 self.send_remove_path
378 .send(PathBufMessage::Finished)
379 .unwrap_or(());
380 break;
381 }
382 }
383
384 if !self.no_prompt {
385 self.display_finished_nonblocking();
386 }
387 }
388
389 self.display_finished_blocking();
390 }
391
392 fn prompt_pathbuf_for_deletion(&mut self, path: &dyn AsRef<std::path::Path>) {
393 if self.no_prompt {
394 self.send_remove_path
395 .send(PathBufMessage::Path(path.as_ref().to_path_buf()))
396 .unwrap_or(());
397 return;
398 }
399 match prompt_for_deletion(&path) {
400 PromptResponse::All => {
401 self.no_prompt = true;
402 self.send_remove_path
403 .send(PathBufMessage::Path(path.as_ref().to_path_buf()))
404 .unwrap_or(());
405 }
406 PromptResponse::Delete => {
407 self.send_remove_path
408 .send(PathBufMessage::Path(path.as_ref().to_path_buf()))
409 .unwrap_or(());
410 }
411 PromptResponse::Skip => (),
412 PromptResponse::Quit => {
413 self.quit = true;
414 self.send_remove_path
415 .send(PathBufMessage::Finished)
416 .unwrap_or(());
417 }
418 }
419 }
420
421 fn display_finished_nonblocking(&self) {
423 let mut printed = false;
424 while let Ok(PathBufMessage::Path(pathbuf)) = self.recv_finished_path.try_recv() {
425 if !printed {
426 printed = true;
427 println!();
428 }
429 print_deleted_pathbuf(&pathbuf);
430 }
431 }
432
433 fn display_finished_blocking(&self) {
435 while let Ok(PathBufMessage::Path(pathbuf)) = self.recv_finished_path.recv() {
436 print_deleted_pathbuf(&pathbuf);
437 }
438 }
439}
440
441fn print_deleted_pathbuf(pathbuf: &std::path::Path) {
443 println!(
444 "{} {}: {}",
445 "#".cyan(),
446 "Deleted".green(),
447 pathbuf.to_string_lossy().blue().bold(),
448 );
449}
450
451pub fn prune(
454 config: &model::Configuration,
455 options: &PruneOptions,
456 paths: &[String],
457) -> Result<i32> {
458 let exit_status: i32 = 0;
459
460 if !options.remove {
461 let msg = "NOTE: Safe mode enabled. Repositories will not be deleted.";
462 println!("{}", msg.green());
463 let msg = "Use '--rm' to enable deletion.";
464 println!("{}", msg.green());
465 }
466
467 cmd::initialize_threads(options.num_jobs)?;
468
469 let (send_repo_path, recv_repo_path) = crossbeam::channel::unbounded();
496 let (send_remove_path, recv_remove_path) = crossbeam::channel::unbounded();
497 let (send_finished_path, recv_finished_path) = crossbeam::channel::unbounded();
498
499 let mut configured_tree_paths = IndexSet::new();
502 {
503 for tree in config.trees.values() {
504 if let Some(pathbuf) = tree.canonical_pathbuf() {
505 configured_tree_paths.insert(pathbuf);
506 }
507 }
508 }
509
510 let root_path = config.root_path.to_path_buf();
511 let path_filters: Vec<std::path::PathBuf> = paths
512 .iter()
513 .map(|value| config.relative_pathbuf(value))
514 .collect();
515
516 rayon::scope_fifo(|scope| {
517 scope.spawn_fifo(|remove_scope| {
519 let remove_paths = RemovePaths {
521 recv_remove_path,
522 send_finished_path,
523 dry_run: !options.remove,
524 };
525 remove_paths.remove_paths(remove_scope);
526 });
527 scope.spawn_fifo(|_| {
528 let quit = false;
530 let mut prompt_user = PromptUser {
531 recv_repo_path,
532 send_remove_path,
533 recv_finished_path,
534 no_prompt: options.no_prompt,
535 quit,
536 };
537 prompt_user.prompt_for_deletion();
538 });
539 scope.spawn_fifo(|_| {
540 let traverse_filesystem = TraverseFilesystem {
543 min_depth: options.min_depth,
544 max_depth: options.max_depth,
545 send_repo_path,
546 root_path,
547 path_filters: &path_filters,
548 configured_tree_paths: &configured_tree_paths,
549 };
550 traverse_filesystem.traverse();
551 });
552 });
553
554 Ok(exit_status)
555}