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<FindRepository>, 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<BString>, 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<Status>) -> walk::Action { let res = self.inner.emit(entry, collapsed_directory_status); if gix::interrupt::is_triggered() { return std::ops::ControlFlow::Break(()); } res } }}
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<FindRepository>,
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<BString>,
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<Status>) -> walk::Action {
let res = self.inner.emit(entry, collapsed_directory_status);
if gix::interrupt::is_triggered() {
return std::ops::ControlFlow::Break(());
}
res
}
}
}