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, /// The path the user is currently in, as seen from the workdir root. worktree_relative_current_dir: Option, } 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 { 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) -> Option { 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 } }