use crate::OutputFormat; #[derive(Default, Copy, Clone)] pub enum FindRepository { #[default] NonBare, All, } pub struct Options { pub debug: bool, pub format: OutputFormat, pub execute: bool, pub ignored: bool, pub precious: bool, pub directories: bool, pub repositories: bool, pub pathspec_matches_result: bool, pub skip_hidden_repositories: Option, pub find_untracked_repositories: FindRepository, } pub(crate) mod function { use std::{borrow::Cow, path::Path}; use anyhow::bail; use gix::{ bstr::{BString, ByteSlice}, dir::{ EntryRef, entry::{Kind, Status}, walk, walk::{EmissionMode::CollapseDirectory, ForDeletionMode::*}, }, }; use crate::{ OutputFormat, repository::clean::{FindRepository, Options}, }; pub fn clean( repo: gix::Repository, out: &mut dyn std::io::Write, err: &mut dyn std::io::Write, patterns: Vec, Options { debug, format, mut execute, ignored, precious, directories, repositories, skip_hidden_repositories, find_untracked_repositories, pathspec_matches_result, }: Options, ) -> anyhow::Result<()> { if format != OutputFormat::Human { bail!("JSON output isn't implemented yet"); } let Some(workdir) = repo.workdir() else { bail!("Need a worktree to clean, this is a bare repository"); }; let index = repo.index_or_empty()?; let pathspec_for_dirwalk = !pathspec_matches_result; let has_patterns = !patterns.is_empty(); let mut collect = InterruptibleCollect::default(); let collapse_directories = CollapseDirectory; let options = repo .dirwalk_options()? .emit_pruned(true) .for_deletion(if (ignored || precious) && directories { match skip_hidden_repositories { Some(FindRepository::NonBare) => Some(FindNonBareRepositoriesInIgnoredDirectories), Some(FindRepository::All) => Some(FindRepositoriesInIgnoredDirectories), None => Some(Default::default()), } } else { Some(Default::default()) }) .classify_untracked_bare_repositories(matches!(find_untracked_repositories, FindRepository::All)) .emit_untracked(collapse_directories) .emit_ignored(Some(collapse_directories)) .empty_patterns_match_prefix(true) .emit_empty_directories(true); repo.dirwalk( &index, if pathspec_for_dirwalk { patterns.clone() } else { Vec::new() }, &gix::interrupt::IS_INTERRUPTED, options, &mut collect, )?; let mut pathspec = pathspec_matches_result .then(|| { repo.pathspec( true, patterns, true, &index, gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, ) }) .transpose()?; let prefix = repo.prefix()?.unwrap_or(Path::new("")); let entries = collect.inner.into_entries_by_path(); let mut entries_to_clean = 0; let mut skipped_directories = 0; let mut skipped_ignored = 0; let mut skipped_precious = 0; let mut skipped_repositories = 0; let mut pruned_entries = 0; let mut saw_ignored_directory = false; let mut saw_untracked_directory = false; for (mut entry, dir_status) in entries.into_iter() { if dir_status.is_some() { if debug { writeln!( err, "DBG: prune '{}' {:?} as parent dir is used instead", entry.rela_path, entry.status ) .ok(); } continue; } let pathspec_includes_entry = match pathspec.as_mut() { None => entry .pathspec_match .is_some_and(|m| m != gix::dir::entry::PathspecMatch::Excluded), Some(pathspec) => pathspec .pattern_matching_relative_path(entry.rela_path.as_bstr(), entry.disk_kind.map(|k| k.is_dir())) .is_some_and(|m| !m.is_excluded()), }; pruned_entries += usize::from(!pathspec_includes_entry); if !pathspec_includes_entry && debug { writeln!(err, "DBG: prune '{}'", entry.rela_path).ok(); } if entry.status.is_pruned() || !pathspec_includes_entry { continue; } let keep = match entry.status { Status::Pruned => { unreachable!("BUG: we skipped these above") } Status::Tracked => { unreachable!("BUG: tracked aren't emitted") } Status::Ignored(gix::ignore::Kind::Expendable) => { skipped_ignored += usize::from(!ignored); ignored } Status::Ignored(gix::ignore::Kind::Precious) => { skipped_precious += usize::from(!precious); precious } Status::Untracked => true, }; if entry.disk_kind.is_none() { entry.disk_kind = workdir .join(gix::path::from_bstr(entry.rela_path.as_bstr())) .symlink_metadata() .ok() .map(|e| e.file_type().into()); } let Some(mut disk_kind) = entry.disk_kind else { if debug { writeln!(err, "DBG: ignoring unreadable entry at '{}' ", entry.rela_path).ok(); } continue; }; if !keep { if debug { writeln!(err, "DBG: prune '{}' as -x or -p is missing", entry.rela_path).ok(); } continue; } if disk_kind == gix::dir::entry::Kind::Directory && gix::discover::is_git(&workdir.join(gix::path::from_bstr(entry.rela_path.as_bstr()))).is_ok() { if debug { writeln!(err, "DBG: upgraded directory '{}' to bare repository", entry.rela_path).ok(); } disk_kind = gix::dir::entry::Kind::Repository; } match disk_kind { Kind::Untrackable => { if debug { writeln!(err, "DBG: skipped untrackable entry at '{}'", entry.rela_path).ok(); } continue; } Kind::File | Kind::Symlink => {} Kind::Directory => { if !directories { skipped_directories += 1; if debug { writeln!(err, "DBG: prune '{}' as -d is missing", entry.rela_path).ok(); } continue; } } Kind::Repository => { if !repositories { skipped_repositories += 1; if debug { writeln!(err, "DBG: skipped repository at '{}'", entry.rela_path)?; } continue; } } } let is_ignored = matches!(entry.status, gix::dir::entry::Status::Ignored(_)); let entry_path = gix::path::from_bstr(entry.rela_path); let display_path = gix::path::relativize_with_prefix(&entry_path, prefix); if disk_kind == gix::dir::entry::Kind::Directory { saw_ignored_directory |= is_ignored; saw_untracked_directory |= entry.status == gix::dir::entry::Status::Untracked; } if gix::interrupt::is_triggered() { execute = false; } let mut may_remove_this_entry = execute; writeln!( out, "{maybe}{suffix} {}{} {status}", display_path.display(), if disk_kind.is_dir() { "/" } else { Default::default() }, status = match entry.status { Status::Ignored(kind) => { Cow::Owned(format!( "({})", match kind { gix::ignore::Kind::Precious => "💲", gix::ignore::Kind::Expendable => "🗑️", } )) } Status::Untracked => { "".into() } status => if debug { format!("(DBG: {status:?})").into() } else { "".into() }, }, maybe = if entry.property == Some(gix::dir::entry::Property::EmptyDirectoryAndCWD) { may_remove_this_entry = false; if execute { "Refusing to remove empty current working directory" } else { "Would refuse to remove empty current working directory" } } else if execute { "removing" } else { "WOULD remove" }, suffix = match disk_kind { Kind::Untrackable => unreachable!("always skipped earlier"), Kind::Directory if entry.property == Some(gix::dir::entry::Property::EmptyDirectory) => { " empty" } Kind::Repository => { " repository" } Kind::File | Kind::Symlink | Kind::Directory => { "" } }, )?; if may_remove_this_entry { let path = workdir.join(entry_path); if disk_kind.is_dir() { std::fs::remove_dir_all(path)?; } else { std::fs::remove_file(path)?; } } else { entries_to_clean += 1; } } if !execute { let mut messages = Vec::new(); messages.extend((skipped_directories > 0).then(|| { format!( "Skipped {skipped_directories} {directories} - show with -d", directories = plural("directory", "directories", skipped_directories) ) })); messages.extend((skipped_repositories > 0).then(|| { format!( "Skipped {skipped_repositories} {repositories} - show with -r", repositories = plural("repository", "repositories", skipped_repositories) ) })); messages.extend((skipped_ignored > 0).then(|| { format!( "Skipped {skipped_ignored} expendable {entries} - show with -x", entries = plural("entry", "entries", skipped_ignored) ) })); messages.extend((skipped_precious > 0).then(|| { format!( "Skipped {skipped_precious} precious {entries} - show with -p", entries = plural("entry", "entries", skipped_precious) ) })); messages.extend((pruned_entries > 0 && has_patterns).then(|| { format!( "try to adjust your pathspec to reveal some of the {pruned_entries} pruned {entries} - show with --debug", entries = plural("entry", "entries", pruned_entries) ) })); let make_msg = || -> String { if messages.is_empty() { return String::new(); } messages.join("; ") }; let wrap_in_parens = |msg: String| if msg.is_empty() { msg } else { format!(" ({msg})") }; if entries_to_clean > 0 { let mut wrote_nl = false; let msg = make_msg(); let mut msg = if msg.is_empty() { None } else { Some(msg) }; if saw_ignored_directory && skip_hidden_repositories.is_none() { writeln!(err).ok(); wrote_nl = true; writeln!( err, "WARNING: would remove repositories hidden inside ignored directories - use --skip-hidden-repositories to skip{}", wrap_in_parens(msg.take().unwrap_or_default()) )?; } if saw_untracked_directory && matches!(find_untracked_repositories, FindRepository::NonBare) { if !wrote_nl { writeln!(err).ok(); wrote_nl = true; } writeln!( err, "WARNING: would remove repositories hidden inside untracked directories - use --find-untracked-repositories to find{}", wrap_in_parens(msg.take().unwrap_or_default()) )?; } if let Some(msg) = msg.take() { if !wrote_nl { writeln!(err).ok(); } writeln!(err, "{msg}").ok(); } } else { writeln!(err, "Nothing to clean{}", wrap_in_parens(make_msg()))?; } if gix::interrupt::is_triggered() { writeln!(err, "Result may be incomplete as it was interrupted")?; } } Ok(()) } fn plural<'a>(one: &'a str, many: &'a str, number: usize) -> &'a str { if number == 1 { one } else { many } } #[derive(Default)] struct InterruptibleCollect { inner: gix::dir::walk::delegate::Collect, } impl gix::dir::walk::Delegate for InterruptibleCollect { fn emit(&mut self, entry: EntryRef<'_>, collapsed_directory_status: Option) -> walk::Action { let res = self.inner.emit(entry, collapsed_directory_status); if gix::interrupt::is_triggered() { return std::ops::ControlFlow::Break(()); } res } } }