395 lines · 14.9 KB · Rust
RawDownload
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        }    }}