Skip to main content

garden/cmds/
prune.rs

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/// Remove unreferenced Git repositories
11#[derive(Parser, Clone, Debug)]
12#[command(author, about, long_about)]
13pub struct PruneOptions {
14    /// Prune repositories in parallel using the specified number of jobs.
15    #[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    /// Set the maximum prune depth
25    #[arg(long, short = 'd', default_value_t = -1)]
26    max_depth: isize,
27    /// Only prune starting at the given depth
28    #[arg(long, default_value_t = -1)]
29    min_depth: isize,
30    /// Only prune at the exact depth. Alias for '--min-depth=# --max-depth=#'
31    #[arg(long, default_value_t = -1)]
32    exact_depth: isize,
33    /// Prune all repositories without prompting (DANGER!)
34    #[arg(long)]
35    no_prompt: bool,
36    /// Enable deletion [default: deletion is disabled]
37    #[arg(long = "rm")]
38    remove: bool,
39    /// Limit pruning to the specified subdirectories
40    paths: Vec<String>,
41}
42
43/// Main entry point for the "garden prune" command
44pub fn main(app_context: &model::ApplicationContext, options: &mut PruneOptions) -> Result<()> {
45    let config = app_context.get_root_config_mut();
46
47    // At least two threads must be running in order for the TraverseFilesystem task to
48    // be able to produce results. Otherwise we'll block in the PromptUser thread without
49    // making progress.
50    if options.num_jobs < 3 {
51        options.num_jobs = 3;
52    }
53
54    // Do not allow min_depth to be greater than max_depth.
55    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    // --exact-depth <depth> is an alias for --min-depth <depth> --max-depth <depth>.
61    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    // Return the last non-zero exit status.
73    errors::exit_status_into_result(exit_status)
74}
75
76/// PathBufMessage is sent across channels between the TraverseFilesystem,
77/// PromptUser and RemovePaths tasks. The Path variant contains a PathBuf to process and
78/// the Finished variant is used to signal the end of the message stream.
79enum PathBufMessage {
80    Path(std::path::PathBuf),
81    Finished,
82}
83
84/// TraverseFilesystem walks the filesystem and sends a PathBufMessage as it
85/// discovers Git repositories during its traversal.
86struct 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    /// Start a parallel traversal over the "paths" Vec.
97    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    /// Traverse all of the top-level directories specified on the command-line.
105    /// This function initiates the recursive walk performed by traverse_subdir().
106    /// The top-level garden root is never removed.
107    fn traverse_toplevel(&self, pathbuf: &std::path::PathBuf) -> std::io::Result<()> {
108        let current_depth: isize = 0;
109        // Traverse over all of the child directories in parallel.
110        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    /// Recursively traverse subdirectories
125    fn traverse_subdir(
126        &self,
127        pathbuf: &std::path::PathBuf,
128        current_depth: isize,
129    ) -> std::io::Result<()> {
130        // Is the current directory a git worktree? We detect this by checking for ".git".
131        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        // Bare repositories are named "foo.git" and have a "git" file extension.
144        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        // Recursively traverse the child subdirectories in parallel.
156        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    /// Validate a pathbuf for traversal.
173    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                // Directories named ".git" are not traversed.
178                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    /// Is the path filtered by the specified path filters?
193    fn is_filtered_path(&self, path: &std::path::Path) -> bool {
194        // When no path filters exist then we can exit immediately.
195        if self.path_filters.is_empty() {
196            return false;
197        }
198
199        // Otherwise the path must be a child directory of a path filter.
200        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        // If no path filter matches then this path should not be traversed.
207        true
208    }
209}
210
211/// Is the value within the min/max bounds.
212/// A max_depth of zero is special-cased to mean unlimited.
213fn 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
217/// Is the value within the max bounds for traversal?
218/// min_depth is not checked for traversal but is checked when emitting messages.
219fn is_within_max_bounds(value: isize, max_depth: isize) -> bool {
220    max_depth == -1 || value <= max_depth
221}
222
223/// The RemovePaths task listens for PathBufMessage messages and removes
224/// paths emitted over the recv_remove_path channel.
225struct RemovePaths {
226    /// Paths to remove are received on this channel from the PromptUser task.
227    recv_remove_path: crossbeam::channel::Receiver<PathBufMessage>,
228    /// Information about paths that have already been removed are reported by
229    /// sending paths to the PromptUser task via the send_finished_path channel.
230    send_finished_path: crossbeam::channel::Sender<PathBufMessage>,
231    /// Dry-run mode does not actually perform deletions.
232    dry_run: bool,
233}
234
235impl RemovePaths {
236    /// Process the recv_remove_path channel and remove paths until no messages remain.
237    fn remove_paths(&self, remove_scope: &rayon::ScopeFifo<'_>) {
238        loop {
239            match self.recv_remove_path.recv() {
240                Ok(PathBufMessage::Path(pathbuf)) => {
241                    // Remove paths from the filesystem and send a completion message.
242                    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                            // Remove empty parent directorires leading up to this path.
248                            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
275/// Responses from the prompt_for_deletion() return this enum.
276enum PromptResponse {
277    All,    // Delete all subsequent entries.
278    Delete, // Delete the current entry.
279    Skip,   // Skip the current entry.
280    Quit,   // Quit and delete nothing.
281}
282
283/// Read input from stdin for whether or not we should delete the current path.
284fn 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        // # <path>
298        println!("{} {}", "#".cyan(), path_string.blue().bold());
299        // # Delete the "xyz" repository?
300        println!(
301            "{}",
302            format!("Delete the \"{path_basename}\" repository?").yellow()
303        );
304        // # "all" deletes "..." and all subsequent repositories.
305        println!(
306            "{}: \"{}\" deletes \"{}\" and {} subsequent repositories!",
307            "WARNING".red().bold(),
308            "all".yellow(),
309            path_basename,
310            "ALL".red().bold(),
311        );
312        // # (yes, no, all, quit) [y,n,a,q]?
313        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" is dangerous so it has no shorthand aliases.
331                "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    /// Display pending "Deleted" messages.
422    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    /// Block and display all of the remaining "Deleted" messages.
434    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
441/// Print a deleted path.
442fn 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
451/// Prune the garden config directory to remove trees that are no longer referenced
452/// by the garden file. This can be run when branches or trees have been removed.
453pub 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    // Channels are used to exchange PathBufMessage messages.
470    // These channels are used to emit repositories that are discovered through a
471    // filesystem traversal, filter paths through user interaction, remove selected
472    // paths from the filesystem and report removed paths to the user.
473    //
474    // The TraverseFilesystem task traverses the filesystem and sends paths to the
475    // PromptUser task through the send_repo_path channels.
476    //
477    // The PromptUser task receives from the recv_repo_path channel and prompts
478    // the user for each path. Paths that are marked for deletion are sent to the
479    // send_remove_path channel.
480    //
481    // The RemovePaths task receives from the recv_remove_path channel and performs
482    // removals from the filesystem. Paths that have finished deleting are sent to the
483    // PromptUser task via the send_finished_path channel.
484    //
485    // The PromptUser task drains the recv_finished_path channel to report paths that
486    // have been deleted.
487    //
488    // TraverseFilesystem.traverse()
489    // -> TraverseFilesystem.send_repo_path
490    // -> PromptUser.recv_repo_path -> prompts for deletion
491    // -> PromptUser.send_remove_path
492    // -> RemovePaths.recv_remove_path -> removes paths
493    // -> RemovePaths.send_finished_path
494    // -> PromptUser.recv_finished_path -> prints deletion messages.
495    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    // Existing trees are never removed. Create an IndexSet containing all of the current
500    // tree paths so that we can skip them while traversing.
501    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        // Spawn tasks in reverse order. Receivers first, senders after.
518        scope.spawn_fifo(|remove_scope| {
519            // RemovePaths handles filesystem removals.
520            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            // PromptUser prompts for confirmation and forwards requests to RemovePaths.
529            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            // TraverseFilesystem searches for Git repositories and sends their paths
541            // into the pipeline for confirmation and removal.
542            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}