gitoxide-core 0.56.0

The library implementing all capabilities of the gitoxide CLI
Documentation
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::{
            entry::{Kind, Status},
            walk,
            walk::{EmissionMode::CollapseDirectory, ForDeletionMode::*},
            EntryRef,
        },
    };

    use crate::{
        repository::clean::{FindRepository, Options},
        OutputFormat,
    };

    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
        }
    }
}