440 lines · 15.4 KB · Rust
RawDownload
use std::{    borrow::Cow,    path::{Path, PathBuf},    sync::atomic::Ordering,}; use bstr::{BStr, BString, ByteSlice}; use crate::{    Entry, EntryRef, entry,    entry::{PathspecMatch, Status},    walk,    walk::{        Action, CollapsedEntriesEmissionMode, Context, Delegate,        EmissionMode::CollapseDirectory,        Error, ForDeletionMode, Options, Outcome, classify,        function::{can_recurse, emit_entry},    },}; /// ### Deviation////// Git mostly silently ignores IO errors and stops iterating seemingly quietly, while we error loudly.#[expect(clippy::too_many_arguments)]pub(super) fn recursive(    may_collapse: bool,    current: &mut PathBuf,    current_bstr: &mut BString,    current_info: classify::Outcome,    ctx: &mut Context<'_>,    opts: Options<'_>,    delegate: &mut dyn Delegate,    out: &mut Outcome,    state: &mut State,) -> Result<(Action, bool), Error> {    if ctx.should_interrupt.is_some_and(|flag| flag.load(Ordering::Relaxed)) {        return Err(Error::Interrupted);    }    out.read_dir_calls += 1;    let entries = gix_fs::read_dir(current, opts.precompose_unicode).map_err(|err| Error::ReadDir {        path: current.to_owned(),        source: err,    })?;     let mut num_entries = 0;    let mark = state.mark(may_collapse);    let mut prevent_collapse = false;    for entry in entries {        let entry = entry.map_err(|err| Error::DirEntry {            parent_directory: current.to_owned(),            source: err,        })?;        // Important to count right away, otherwise the directory could be seen as empty even though it's not.        // That is, this should be independent of the kind.        num_entries += 1;         let prev_len = current_bstr.len();        if prev_len != 0 {            current_bstr.push(b'/');        }        let file_name = entry.file_name();        current_bstr.extend_from_slice(            gix_path::try_os_str_into_bstr(Cow::Borrowed(file_name.as_ref()))                .expect("no illformed UTF-8")                .as_ref(),        );        current.push(file_name);         let mut info = classify::path(            current,            current_bstr,            if prev_len == 0 { 0 } else { prev_len + 1 },            None,            || entry.file_type().ok().map(Into::into),            opts,            ctx,        )?;         if can_recurse(            current_bstr.as_bstr(),            info,            opts.for_deletion,            false, /* is root */            delegate,        ) {            let subdir_may_collapse = state.may_collapse(current);            let (action, subdir_prevent_collapse) = recursive(                subdir_may_collapse,                current,                current_bstr,                info,                ctx,                opts,                delegate,                out,                state,            )?;            prevent_collapse |= subdir_prevent_collapse;            if action.is_break() {                return Ok((action, prevent_collapse));            }        } else {            if opts.for_deletion == Some(ForDeletionMode::IgnoredDirectoriesCanHideNestedRepositories)                && info.disk_kind == Some(entry::Kind::Directory)                && matches!(info.status, Status::Ignored(_))            {                info.disk_kind = classify::maybe_upgrade_to_repository(                    info.disk_kind,                    true,                    false,                    current,                    ctx.current_dir,                    ctx.git_dir_realpath,                );            }            if !state.held_for_directory_collapse(current_bstr.as_bstr(), info, &opts) {                let action = emit_entry(Cow::Borrowed(current_bstr.as_bstr()), info, None, opts, out, delegate);                if action.is_break() {                    return Ok((action, prevent_collapse));                }            }        }        current_bstr.truncate(prev_len);        current.pop();    }     let res = mark.reduce_held_entries(        num_entries,        state,        &mut prevent_collapse,        current,        current_bstr.as_bstr(),        current_info,        opts,        out,        ctx,        delegate,    );    Ok((res, prevent_collapse))} pub(super) struct State {    /// The entries to hold back until it's clear what to do with them.    pub on_hold: Vec<Entry>,    /// The path the user is currently in, as seen from the workdir root.    worktree_relative_current_dir: Option<PathBuf>,} impl State {    /// Hold the entry with the given `status` if it's a candidate for collapsing the containing directory.    fn held_for_directory_collapse(&mut self, rela_path: &BStr, info: classify::Outcome, opts: &Options<'_>) -> bool {        if opts.should_hold(info.status) {            self.on_hold                .push(EntryRef::from_outcome(Cow::Borrowed(rela_path), info).into_owned());            true        } else {            false        }    }     /// Keep track of state we need to later resolve the state.    /// Top-level directories are special, as they don't fold.    fn mark(&self, may_collapse: bool) -> Mark {        Mark {            start_index: self.on_hold.len(),            may_collapse,        }    }     pub(super) fn new(worktree_root: &Path, current_dir: &Path, is_delete_mode: bool) -> Self {        let worktree_relative_current_dir = if is_delete_mode {            gix_path::realpath_opts(worktree_root, current_dir, gix_path::realpath::MAX_SYMLINKS)                .ok()                .and_then(|real_worktree_root| current_dir.strip_prefix(real_worktree_root).ok().map(ToOwned::to_owned))                .map(|relative_cwd| worktree_root.join(relative_cwd))        } else {            None        };        Self {            on_hold: Vec::new(),            worktree_relative_current_dir,        }    }     /// Returns `true` if the worktree-relative `directory_to_traverse` is not the current working directory.    /// This is only the case when    pub(super) fn may_collapse(&self, directory_to_traverse: &Path) -> bool {        self.worktree_relative_current_dir            .as_ref()            .is_none_or(|cwd| cwd != directory_to_traverse)    }     pub(super) fn emit_remaining(        &mut self,        may_collapse: bool,        opts: Options<'_>,        out: &mut walk::Outcome,        delegate: &mut dyn walk::Delegate,    ) {        if self.on_hold.is_empty() {            return;        }         _ = Mark {            start_index: 0,            may_collapse,        }        .emit_all_held(self, opts, out, delegate);    }} struct Mark {    start_index: usize,    may_collapse: bool,} impl Mark {    #[expect(clippy::too_many_arguments)]    fn reduce_held_entries(        mut self,        num_entries: usize,        state: &mut State,        prevent_collapse: &mut bool,        dir_path: &Path,        dir_rela_path: &BStr,        dir_info: classify::Outcome,        opts: Options<'_>,        out: &mut walk::Outcome,        ctx: &mut Context<'_>,        delegate: &mut dyn walk::Delegate,    ) -> walk::Action {        if num_entries == 0 {            let empty_info = classify::Outcome {                property: {                    assert_ne!(                        dir_info.disk_kind,                        Some(entry::Kind::Repository),                        "BUG: it shouldn't be possible to classify an empty dir as repository"                    );                    if !state.may_collapse(dir_path) {                        *prevent_collapse = true;                        entry::Property::EmptyDirectoryAndCWD.into()                    } else if dir_info.property.is_none() {                        entry::Property::EmptyDirectory.into()                    } else {                        dir_info.property                    }                },                pathspec_match: ctx                    .pathspec                    .pattern_matching_relative_path(dir_rela_path, Some(true), ctx.pathspec_attributes)                    .map(|m| m.kind.into()),                ..dir_info            };            if opts.should_hold(empty_info.status) {                state                    .on_hold                    .push(EntryRef::from_outcome(Cow::Borrowed(dir_rela_path), empty_info).into_owned());                std::ops::ControlFlow::Continue(())            } else {                emit_entry(Cow::Borrowed(dir_rela_path), empty_info, None, opts, out, delegate)            }        } else if *prevent_collapse {            self.emit_all_held(state, opts, out, delegate)        } else if let Some(action) = self.try_collapse(dir_rela_path, dir_info, state, out, opts, ctx, delegate) {            action        } else {            *prevent_collapse = true;            self.emit_all_held(state, opts, out, delegate)        }    }     fn emit_all_held(        &mut self,        state: &mut State,        opts: Options<'_>,        out: &mut walk::Outcome,        delegate: &mut dyn walk::Delegate,    ) -> Action {        for entry in state.on_hold.drain(self.start_index..) {            let info = classify::Outcome::from(&entry);            let action = emit_entry(Cow::Owned(entry.rela_path), info, None, opts, out, delegate);            if action.is_break() {                return action;            }        }        std::ops::ControlFlow::Continue(())    }     #[expect(clippy::too_many_arguments)]    fn try_collapse(        &self,        dir_rela_path: &BStr,        dir_info: classify::Outcome,        state: &mut State,        out: &mut walk::Outcome,        opts: Options<'_>,        ctx: &mut Context<'_>,        delegate: &mut dyn walk::Delegate,    ) -> Option<Action> {        if !self.may_collapse {            return None;        }        let (mut expendable, mut precious, mut untracked, mut entries, mut matching_entries) = (0, 0, 0, 0, 0);        for (kind, status, pathspec_match) in state.on_hold[self.start_index..]            .iter()            .map(|e| (e.disk_kind, e.status, e.pathspec_match))        {            entries += 1;            if kind == Some(entry::Kind::Repository) {                return None;            }            if pathspec_match.is_some_and(|m| matches!(m, PathspecMatch::Verbatim | PathspecMatch::Excluded)) {                return None;            }            matching_entries += usize::from(pathspec_match.is_some_and(|m| !m.should_ignore()));            match status {                Status::Pruned => {                    unreachable!("BUG: pruned aren't ever held, check `should_hold()`")                }                Status::Tracked => { /* causes the folder not to be collapsed */ }                Status::Ignored(gix_ignore::Kind::Expendable) => expendable += 1,                Status::Ignored(gix_ignore::Kind::Precious) => precious += 1,                Status::Untracked => untracked += 1,            }        }         if matching_entries != 0 && matching_entries != entries {            return None;        }         let dir_status = if opts.emit_untracked == CollapseDirectory            && untracked != 0            && untracked + expendable + precious == entries            && (opts.for_deletion.is_none()                || (precious == 0 && expendable == 0)                || (precious == 0 && opts.emit_ignored.is_some()))        {            entry::Status::Untracked        } else if opts.emit_ignored == Some(CollapseDirectory) {            if expendable != 0 && expendable == entries {                entry::Status::Ignored(gix_ignore::Kind::Expendable)            } else if precious != 0 && precious == entries {                entry::Status::Ignored(gix_ignore::Kind::Precious)            } else {                return None;            }        } else {            return None;        };         if !matches!(dir_status, entry::Status::Untracked | entry::Status::Ignored(_)) {            return None;        }         if !ctx.pathspec.directory_matches_prefix(dir_rela_path, false) {            return None;        }         // Pathspecs affect the collapse of the next level, hence find the highest-value one.        let dir_pathspec_match = state.on_hold[self.start_index..]            .iter()            .filter_map(|e| e.pathspec_match)            .max()            .or_else(|| {                // Only take directory matches as value if they are above the 'guessed' ones.                // Otherwise we end up with seemingly matched entries in the parent directory which                // affects proper folding.                filter_dir_pathspec(dir_info.pathspec_match)            });         // If every collapsed entry is itself an empty directory, the collapsed directory has no files        // to track. Keep the `EmptyDirectory` property so callers that skip empty directories by        // default (such as `gix status`) behave like Git, which treats a tree of only empty        // directories as clean. See https://github.com/GitoxideLabs/gitoxide/issues/2490.        //        // Restricted to untracked collapses in non-deletion walks: deletion has its own handling of        // empty directories, and an explicitly requested *ignored* directory must stay observable even        // when empty (it would otherwise be filtered out where empty directories aren't emitted).        let collapsed_property = (opts.for_deletion.is_none()            && matches!(dir_status, entry::Status::Untracked)            && state.on_hold[self.start_index..]                .iter()                .all(|entry| entry.property == Some(entry::Property::EmptyDirectory)))        .then_some(entry::Property::EmptyDirectory);         let mut removed_without_emitting = 0;        let mut action = std::ops::ControlFlow::Continue(());        for entry in state.on_hold.drain(self.start_index..) {            if action.is_break() {                removed_without_emitting += 1;                continue;            }            match opts.emit_collapsed {                Some(mode) => {                    if mode == CollapsedEntriesEmissionMode::All || entry.status != dir_status {                        let info = classify::Outcome::from(&entry);                        action = emit_entry(Cow::Owned(entry.rela_path), info, Some(dir_status), opts, out, delegate);                    } else {                        removed_without_emitting += 1;                    }                }                None => removed_without_emitting += 1,            }        }        out.seen_entries += removed_without_emitting as u32;         state.on_hold.push(            EntryRef::from_outcome(                Cow::Borrowed(dir_rela_path),                classify::Outcome {                    status: dir_status,                    property: dir_info.property.or(collapsed_property),                    pathspec_match: dir_pathspec_match,                    ..dir_info                },            )            .into_owned(),        );        Some(action)    }} fn filter_dir_pathspec(current: Option<PathspecMatch>) -> Option<PathspecMatch> {    current.filter(|m| {        matches!(            m,            PathspecMatch::Always | PathspecMatch::WildcardMatch | PathspecMatch::Verbatim        )    })} impl Options<'_> {    fn should_hold(&self, status: entry::Status) -> bool {        if status.is_pruned() {            return false;        }        self.emit_ignored == Some(CollapseDirectory) || self.emit_untracked == CollapseDirectory    }}