diff --git a/Cargo.lock b/Cargo.lock index 88476811bee..e48ef022a01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2518,6 +2518,7 @@ dependencies = [ "gix-index 0.30.0", "gix-object 0.41.0", "gix-path 0.10.5", + "gix-pathspec", "gix-worktree 0.31.0", "thiserror", ] @@ -2536,6 +2537,7 @@ dependencies = [ "gix-pathspec", "gix-status", "gix-testtools", + "gix-worktree 0.31.0", ] [[package]] diff --git a/gitoxide-core/Cargo.toml b/gitoxide-core/Cargo.toml index 23daad6577d..295b1a14608 100644 --- a/gitoxide-core/Cargo.toml +++ b/gitoxide-core/Cargo.toml @@ -47,7 +47,7 @@ serde = ["gix/serde", "dep:serde_json", "dep:serde", "bytesize/serde"] [dependencies] # deselect everything else (like "performance") as this should be controllable by the parent application. -gix = { version = "^0.59.0", path = "../gix", default-features = false, features = ["blob-diff", "revision", "mailmap", "excludes", "attributes", "worktree-mutation", "credentials", "interrupt", "status"] } +gix = { version = "^0.59.0", path = "../gix", default-features = false, features = ["blob-diff", "revision", "mailmap", "excludes", "attributes", "worktree-mutation", "credentials", "interrupt", "status", "dirwalk"] } gix-pack-for-configuration-only = { package = "gix-pack", version = "^0.48.0", path = "../gix-pack", default-features = false, features = ["pack-cache-lru-dynamic", "pack-cache-lru-static", "generate", "streaming-input"] } gix-transport-configuration-only = { package = "gix-transport", version = "^0.41.0", path = "../gix-transport", default-features = false } gix-archive-for-configuration-only = { package = "gix-archive", version = "^0.9.0", path = "../gix-archive", optional = true, features = ["tar", "tar_gz"] } diff --git a/gitoxide-core/src/query/engine/command.rs b/gitoxide-core/src/query/engine/command.rs index 5498af56046..31246b0676d 100644 --- a/gitoxide-core/src/query/engine/command.rs +++ b/gitoxide-core/src/query/engine/command.rs @@ -23,6 +23,7 @@ impl query::Engine { let relpath = self .repo .pathspec( + true, Some(spec.to_bstring()), false, &gix::index::State::new(self.repo.object_hash()), diff --git a/gitoxide-core/src/repository/attributes/query.rs b/gitoxide-core/src/repository/attributes/query.rs index f8d665f6bd3..09d1b0a0b48 100644 --- a/gitoxide-core/src/repository/attributes/query.rs +++ b/gitoxide-core/src/repository/attributes/query.rs @@ -10,7 +10,7 @@ pub struct Options { pub(crate) mod function { use std::{borrow::Cow, io, path::Path}; - use anyhow::{anyhow, bail}; + use anyhow::bail; use gix::bstr::BStr; use crate::{ @@ -52,6 +52,7 @@ pub(crate) mod function { } PathsOrPatterns::Patterns(patterns) => { let mut pathspec = repo.pathspec( + true, patterns.iter(), true, &index, @@ -59,36 +60,37 @@ pub(crate) mod function { .adjust_for_bare(repo.is_bare()), )?; let mut pathspec_matched_entry = false; - for (path, _entry) in pathspec - .index_entries_with_paths(&index) - .ok_or_else(|| anyhow!("Pathspec didn't match a single path in the index"))? - { - pathspec_matched_entry = true; - let entry = cache.at_entry(path, Some(false))?; - if !entry.matching_attributes(&mut matches) { - continue; + if let Some(it) = pathspec.index_entries_with_paths(&index) { + for (path, _entry) in it { + pathspec_matched_entry = true; + let entry = cache.at_entry(path, Some(false))?; + if !entry.matching_attributes(&mut matches) { + continue; + } + print_match(&matches, path, &mut out)?; } - print_match(&matches, path, &mut out)?; } if !pathspec_matched_entry { // TODO(borrowchk): this shouldn't be necessary at all, but `pathspec` stays borrowed mutably for some reason. // It's probably due to the strange lifetimes of `index_entries_with_paths()`. let pathspec = repo.pathspec( + true, patterns.iter(), true, &index, gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping .adjust_for_bare(repo.is_bare()), )?; + let workdir = repo.work_dir(); for pattern in pathspec.search().patterns() { let path = pattern.path(); let entry = cache.at_entry( path, - pattern - .signature - .contains(gix::pathspec::MagicSignature::MUST_BE_DIR) - .into(), + Some( + workdir.map_or(false, |wd| wd.join(gix::path::from_bstr(path)).is_dir()) + || pattern.signature.contains(gix::pathspec::MagicSignature::MUST_BE_DIR), + ), )?; if !entry.matching_attributes(&mut matches) { continue; diff --git a/gitoxide-core/src/repository/clean.rs b/gitoxide-core/src/repository/clean.rs index 29c2517f621..f570129987d 100644 --- a/gitoxide-core/src/repository/clean.rs +++ b/gitoxide-core/src/repository/clean.rs @@ -27,7 +27,9 @@ pub(crate) mod function { use gix::dir::entry::{Kind, Status}; use gix::dir::walk::EmissionMode::CollapseDirectory; use gix::dir::walk::ForDeletionMode::*; + use gix::dir::{walk, EntryRef}; use std::borrow::Cow; + use std::path::Path; pub fn clean( repo: gix::Repository, @@ -37,7 +39,7 @@ pub(crate) mod function { Options { debug, format, - execute, + mut execute, ignored, precious, directories, @@ -53,9 +55,9 @@ pub(crate) mod function { bail!("Need a worktree to clean, this is a bare repository"); }; - let index = repo.index()?; + let index = repo.index_or_empty()?; let has_patterns = !patterns.is_empty(); - let mut collect = gix::dir::walk::delegate::Collect::default(); + let mut collect = InterruptableCollect::default(); let collapse_directories = CollapseDirectory; let options = repo .dirwalk_options()? @@ -72,16 +74,12 @@ pub(crate) mod function { .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, patterns, options, &mut collect)?; - let prefix = repo.prefix()?.expect("worktree and valid current dir"); - let prefix_len = if prefix.as_os_str().is_empty() { - 0 - } else { - prefix.to_str().map_or(0, |s| s.len() + 1 /* slash */) - }; + let prefix = repo.prefix()?.unwrap_or(Path::new("")); - let entries = collect.into_entries_by_path(); + let entries = collect.inner.into_entries_by_path(); let mut entries_to_clean = 0; let mut skipped_directories = 0; let mut skipped_ignored = 0; @@ -143,7 +141,7 @@ pub(crate) mod function { && 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 repository", entry.rela_path).ok(); + writeln!(err, "DBG: upgraded directory '{}' to bare repository", entry.rela_path).ok(); } disk_kind = gix::dir::entry::Kind::Repository; } @@ -171,7 +169,8 @@ pub(crate) mod function { }; let is_ignored = matches!(entry.status, gix::dir::entry::Status::Ignored(_)); - let display_path = entry.rela_path[prefix_len..].as_bstr(); + 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; @@ -179,7 +178,7 @@ pub(crate) mod function { writeln!( out, "{maybe}{suffix} {}{} {status}", - display_path, + display_path.display(), disk_kind.is_dir().then_some("/").unwrap_or_default(), status = match entry.status { Status::Ignored(kind) => { @@ -215,8 +214,11 @@ pub(crate) mod function { }, )?; + if gix::interrupt::is_triggered() { + execute = false; + } if execute { - let path = workdir.join(gix::path::from_bstr(entry.rela_path)); + let path = workdir.join(entry_path); if disk_kind.is_dir() { std::fs::remove_dir_all(path)?; } else { @@ -286,7 +288,25 @@ pub(crate) mod function { } 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(()) } + + #[derive(Default)] + struct InterruptableCollect { + inner: gix::dir::walk::delegate::Collect, + } + + impl gix::dir::walk::Delegate for InterruptableCollect { + fn emit(&mut self, entry: EntryRef<'_>, collapsed_directory_status: Option) -> walk::Action { + let res = self.inner.emit(entry, collapsed_directory_status); + if gix::interrupt::is_triggered() { + return walk::Action::Cancel; + } + res + } + } } diff --git a/gitoxide-core/src/repository/exclude.rs b/gitoxide-core/src/repository/exclude.rs index 1bc3eddf556..ac837fe0303 100644 --- a/gitoxide-core/src/repository/exclude.rs +++ b/gitoxide-core/src/repository/exclude.rs @@ -1,6 +1,6 @@ use std::{borrow::Cow, io}; -use anyhow::{anyhow, bail}; +use anyhow::bail; use gix::bstr::BStr; use crate::{repository::PathsOrPatterns, OutputFormat}; @@ -58,42 +58,44 @@ pub fn query( PathsOrPatterns::Patterns(patterns) => { let mut pathspec_matched_something = false; let mut pathspec = repo.pathspec( + true, patterns.iter(), repo.work_dir().is_some(), &index, gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping.adjust_for_bare(repo.is_bare()), )?; - for (path, _entry) in pathspec - .index_entries_with_paths(&index) - .ok_or_else(|| anyhow!("Pathspec didn't yield any entry"))? - { - pathspec_matched_something = true; - let entry = cache.at_entry(path, Some(false))?; - let match_ = entry - .matching_exclude_pattern() - .and_then(|m| (show_ignore_patterns || !m.pattern.is_negative()).then_some(m)); - print_match(match_, path, &mut out)?; + if let Some(it) = pathspec.index_entries_with_paths(&index) { + for (path, _entry) in it { + pathspec_matched_something = true; + let entry = cache.at_entry(path, Some(false))?; + let match_ = entry + .matching_exclude_pattern() + .and_then(|m| (show_ignore_patterns || !m.pattern.is_negative()).then_some(m)); + print_match(match_, path, &mut out)?; + } } if !pathspec_matched_something { // TODO(borrowchk): this shouldn't be necessary at all, but `pathspec` stays borrowed mutably for some reason. // It's probably due to the strange lifetimes of `index_entries_with_paths()`. let pathspec = repo.pathspec( + true, patterns.iter(), repo.work_dir().is_some(), &index, gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping .adjust_for_bare(repo.is_bare()), )?; + let workdir = repo.work_dir(); for pattern in pathspec.search().patterns() { let path = pattern.path(); let entry = cache.at_entry( path, - pattern - .signature - .contains(gix::pathspec::MagicSignature::MUST_BE_DIR) - .into(), + Some( + workdir.map_or(false, |wd| wd.join(gix::path::from_bstr(path)).is_dir()) + || pattern.signature.contains(gix::pathspec::MagicSignature::MUST_BE_DIR), + ), )?; let match_ = entry .matching_exclude_pattern() diff --git a/gitoxide-core/src/repository/index/entries.rs b/gitoxide-core/src/repository/index/entries.rs index 71f7b1b1862..b4f335e6ae3 100644 --- a/gitoxide-core/src/repository/index/entries.rs +++ b/gitoxide-core/src/repository/index/entries.rs @@ -256,6 +256,7 @@ pub(crate) mod function { )> { let index = repo.index_or_load_from_head()?; let pathspec = repo.pathspec( + true, pathspecs, false, &index, diff --git a/gitoxide-core/src/repository/status.rs b/gitoxide-core/src/repository/status.rs index 6d4207a03f1..ee36b1b1f3f 100644 --- a/gitoxide-core/src/repository/status.rs +++ b/gitoxide-core/src/repository/status.rs @@ -1,10 +1,12 @@ use anyhow::{bail, Context}; +use gix::bstr::ByteSlice; use gix::{ bstr::{BStr, BString}, index::Entry, Progress, }; use gix_status::index_as_worktree::{traits::FastEq, Change, Conflict, EntryStatus}; +use std::path::{Path, PathBuf}; use crate::OutputFormat; @@ -45,52 +47,93 @@ pub fn show( } let mut index = repo.index_or_empty()?; let index = gix::threading::make_mut(&mut index); - let pathspec = repo.pathspec( - pathspecs, - true, - index, - gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, - )?; let mut progress = progress.add_child("traverse index"); let start = std::time::Instant::now(); + let stack = repo + .attributes_only( + index, + gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, + )? + .detach(); + let pathspec = gix::Pathspec::new(&repo, false, pathspecs.iter().map(|p| p.as_bstr()), true, || { + Ok(stack.clone()) + })?; let options = gix_status::index_as_worktree::Options { fs: repo.filesystem_options()?, thread_limit, stat: repo.stat_options()?, - attributes: match repo - .attributes_only( - index, - gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, - )? - .detach() - .state_mut() - { - gix::worktree::stack::State::AttributesStack(attrs) => std::mem::take(attrs), - // TODO: this should be nicer by creating attributes directly, but it's a private API - _ => unreachable!("state must be attributes stack only"), - }, }; + let prefix = repo.prefix()?.unwrap_or(Path::new("")); let mut printer = Printer { out, changes: Vec::new(), + prefix: prefix.to_owned(), + }; + let filter_pipeline = repo + .filter_pipeline(Some(gix::hash::ObjectId::empty_tree(repo.object_hash())))? + .0 + .into_parts() + .0; + let ctx = gix_status::index_as_worktree::Context { + pathspec: pathspec.into_parts().0, + stack, + filter: filter_pipeline, + should_interrupt: &gix::interrupt::IS_INTERRUPTED, }; - let outcome = gix_status::index_as_worktree( - index, - repo.work_dir() - .context("This operation cannot be run on a bare repository")?, - &mut printer, - FastEq, - Submodule, - repo.objects.clone().into_arc()?, - &mut progress, - pathspec.detach()?, - repo.filter_pipeline(Some(gix::hash::ObjectId::empty_tree(repo.object_hash())))? - .0 - .into_parts() - .0, - &gix::interrupt::IS_INTERRUPTED, - options, - )?; + let mut collect = gix::dir::walk::delegate::Collect::default(); + let (outcome, walk_outcome) = gix::features::parallel::threads(|scope| -> anyhow::Result<_> { + // TODO: it's either this, or not running both in parallel and setting UPTODATE flags whereever + // there is no modification. This can save disk queries as dirwalk can then trust what's in + // the index regarding the type. + // NOTE: collect here as rename-tracking needs that anyway. + let walk_outcome = gix::features::parallel::build_thread() + .name("gix status::dirwalk".into()) + .spawn_scoped(scope, { + let repo = repo.clone().into_sync(); + let index = &index; + let collect = &mut collect; + move || -> anyhow::Result<_> { + let repo = repo.to_thread_local(); + let outcome = repo.dirwalk( + index, + pathspecs, + repo.dirwalk_options()? + .emit_untracked(gix::dir::walk::EmissionMode::CollapseDirectory), + collect, + )?; + Ok(outcome.dirwalk) + } + })?; + + let outcome = gix_status::index_as_worktree( + index, + repo.work_dir() + .context("This operation cannot be run on a bare repository")?, + &mut printer, + FastEq, + Submodule, + repo.objects.clone().into_arc()?, + &mut progress, + ctx, + options, + )?; + + let walk_outcome = walk_outcome.join().expect("no panic")?; + Ok((outcome, walk_outcome)) + })?; + + for entry in collect + .into_entries_by_path() + .into_iter() + .filter_map(|(entry, dir_status)| dir_status.is_none().then_some(entry)) + { + writeln!( + printer.out, + "{status: >3} {rela_path}", + status = "?", + rela_path = gix::path::relativize_with_prefix(&gix::path::from_bstr(entry.rela_path), prefix).display() + )?; + } if outcome.entries_to_update != 0 && allow_write { { @@ -115,6 +158,7 @@ pub fn show( if statistics { writeln!(err, "{outcome:#?}").ok(); + writeln!(err, "{walk_outcome:#?}").ok(); } writeln!(err, "\nhead -> index and untracked files aren't implemented yet")?; @@ -137,6 +181,7 @@ impl gix_status::index_as_worktree::traits::SubmoduleStatus for Submodule { struct Printer { out: W, changes: Vec<(usize, ApplyChange)>, + prefix: PathBuf, } enum ApplyChange { @@ -188,7 +233,9 @@ impl Printer { EntryStatus::IntentToAdd => "A", }; - writeln!(&mut self.out, "{status: >3} {rela_path}") + let rela_path = gix::path::from_bstr(rela_path); + let display_path = gix::path::relativize_with_prefix(&rela_path, &self.prefix); + writeln!(&mut self.out, "{status: >3} {}", display_path.display()) } } diff --git a/gix-dir/src/walk/classify.rs b/gix-dir/src/walk/classify.rs index 523e0690fb8..1a0b19b0d9d 100644 --- a/gix-dir/src/walk/classify.rs +++ b/gix-dir/src/walk/classify.rs @@ -164,10 +164,6 @@ pub fn path( m.kind.into() } }); - if let Some(status) = maybe_status { - return Ok(out.with_status(status).with_kind(kind, index_kind)); - } - debug_assert!(maybe_status.is_none(), "It only communicates a single stae right now"); let mut maybe_upgrade_to_repository = |current_kind, find_harder: bool| { if recurse_repositories { @@ -202,7 +198,14 @@ pub fn path( current_kind } }; + if let Some(status) = maybe_status { + if kind == Some(entry::Kind::Directory) && index_kind == Some(entry::Kind::Repository) { + kind = maybe_upgrade_to_repository(kind, false); + } + return Ok(out.with_status(status).with_kind(kind, index_kind)); + } + debug_assert!(maybe_status.is_none(), "It only communicates a single stae right now"); if let Some(excluded) = ctx .excludes .as_mut() diff --git a/gix-dir/src/walk/function.rs b/gix-dir/src/walk/function.rs index d8015a98fd0..1017d1d3451 100644 --- a/gix-dir/src/walk/function.rs +++ b/gix-dir/src/walk/function.rs @@ -8,16 +8,20 @@ use crate::{entry, EntryRef}; /// A function to perform a git-style, unsorted, directory walk. /// -/// * `root` - the starting point of the walk and a readable directory. -/// - Note that if the path leading to this directory or `root` itself is excluded, it will be provided to [`Delegate::emit()`] -/// without further traversal. -/// - If [`Options::precompose_unicode`] is enabled, this path must be precomposed. -/// - Must be contained in `worktree_root`. /// * `worktree_root` - the top-most root of the worktree, which must be a prefix to `root`. /// - If [`Options::precompose_unicode`] is enabled, this path must be precomposed. +/// - The starting point of the traversal (traversal root) is calculated from by doing `worktree_root + pathspec.common_prefix()`. +/// - Note that if the traversal root leading to this directory or it itself is excluded, it will be provided to [`Delegate::emit()`] +/// without further traversal. +/// - If [`Options::precompose_unicode`] is enabled, all involved paths must be precomposed. +/// - Must be contained in `worktree_root`. /// * `ctx` - everything needed to classify the paths seen during the traversal. /// * `delegate` - an implementation of [`Delegate`] to control details of the traversal and receive its results. /// +/// Returns `(outcome, traversal_root)`, with the `traversal_root` actually being used for the traversal, +/// useful to transform the paths returned for the user. It's always within the `worktree_root`, or the same, +/// but is hard to guess due to additional logic affecting it. +/// /// ### Performance Notes /// /// In theory, parallel directory traversal can be significantly faster, and what's possible for our current @@ -36,14 +40,24 @@ use crate::{entry, EntryRef}; /// or 0.25s for optimal multi-threaded performance, all in the WebKit directory with 388k items to traverse. /// Thus, the speedup could easily be 2x or more and thus worth investigating in due time. pub fn walk( - root: &Path, worktree_root: &Path, mut ctx: Context<'_>, options: Options, delegate: &mut dyn Delegate, -) -> Result { +) -> Result<(Outcome, PathBuf), Error> { + let root = match ctx.explicit_traversal_root { + Some(root) => root.to_owned(), + None => ctx + .pathspec + .longest_common_directory() + .and_then(|candidate| { + let candidate = worktree_root.join(candidate); + candidate.is_dir().then_some(candidate) + }) + .unwrap_or_else(|| worktree_root.join(ctx.pathspec.prefix_directory())), + }; let _span = gix_trace::coarse!("walk", root = ?root, worktree_root = ?worktree_root, options = ?options); - let (mut current, worktree_root_relative) = assure_no_symlink_in_root(worktree_root, root)?; + let (mut current, worktree_root_relative) = assure_no_symlink_in_root(worktree_root, &root)?; let mut out = Outcome::default(); let mut buf = BString::default(); let root_info = classify::root( @@ -68,12 +82,13 @@ pub fn walk( &mut out, delegate, ); - return Ok(out); + return Ok((out, root.to_owned())); } - let mut state = readdir::State::default(); - let _ = readdir::recursive( - root == worktree_root, + let mut state = readdir::State::new(worktree_root, ctx.current_dir, options.for_deletion.is_some()); + let may_collapse = root != worktree_root && state.may_collapse(¤t); + let (action, _) = readdir::recursive( + may_collapse, &mut current, &mut buf, root_info, @@ -83,9 +98,12 @@ pub fn walk( &mut out, &mut state, )?; - assert_eq!(state.on_hold.len(), 0, "BUG: must be fully consumed"); + if action != Action::Cancel { + state.emit_remaining(may_collapse, options, &mut out, delegate); + assert_eq!(state.on_hold.len(), 0, "BUG: after emission, on hold must be empty"); + } gix_trace::debug!(statistics = ?out); - Ok(out) + Ok((out, root.to_owned())) } /// Note that we only check symlinks on the way from `worktree_root` to `root`, @@ -96,10 +114,9 @@ fn assure_no_symlink_in_root<'root>( root: &'root Path, ) -> Result<(PathBuf, Cow<'root, Path>), Error> { let mut current = worktree_root.to_owned(); - let worktree_relative = root.strip_prefix(worktree_root).map_err(|_| Error::RootNotInWorktree { - worktree_root: worktree_root.to_owned(), - root: root.to_owned(), - })?; + let worktree_relative = root + .strip_prefix(worktree_root) + .expect("BUG: root was created from worktree_root + prefix"); let worktree_relative = gix_path::normalize(worktree_relative.into(), Path::new("")) .ok_or(Error::NormalizeRoot { root: root.to_owned() })?; diff --git a/gix-dir/src/walk/mod.rs b/gix-dir/src/walk/mod.rs index 5929d42ca47..3f593c40d9b 100644 --- a/gix-dir/src/walk/mod.rs +++ b/gix-dir/src/walk/mod.rs @@ -58,8 +58,9 @@ pub trait Delegate { /// item isn't yet known. Pruned entries are also only emitted if [`Options::emit_pruned`] is `true`. /// /// `collapsed_directory_status` is `Some(dir_status)` if this entry was part of a directory with the given - /// `dir_status` that wasn't the same as the one of `entry`. Depending on the operation, these then want to be - /// used or discarded. + /// `dir_status` that wasn't the same as the one of `entry` and if [Options::emit_collapsed] was + /// [CollapsedEntriesEmissionMode::OnStatusMismatch]. It will also be `Some(dir_status)` if that option + /// was [CollapsedEntriesEmissionMode::All]. fn emit(&mut self, entry: EntryRef<'_>, collapsed_directory_status: Option) -> Action; /// Return `true` if the given entry can be recursed into. Will only be called if the entry is a physical directory. @@ -94,6 +95,23 @@ pub enum EmissionMode { CollapseDirectory, } +/// The way entries that are contained in collapsed directories are emitted using the [Delegate]. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub enum CollapsedEntriesEmissionMode { + /// Emit only entries if their status does not match the one of the parent directory that is + /// going to be collapsed. + /// + /// E.g. if a directory is determined to be untracked, and the entries in question are ignored, + /// they will be emitted. + /// + /// Entries that have the same status will essentially be 'merged' into the collapsing directory + /// and won't be observable anymore. + #[default] + OnStatusMismatch, + /// Emit all entries inside of a collapsed directory to make them observable. + All, +} + /// When the walk is for deletion, assure that we don't collapse directories that have precious files in /// them, and otherwise assure that no entries are observable that shouldn't be deleted. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] @@ -151,6 +169,8 @@ pub struct Options { /// subdirectories, as long as none of them includes a file. /// Thus, this makes leaf-level empty directories visible, as those don't have any content. pub emit_empty_directories: bool, + /// If `None`, no entries inside of collapsed directories are emitted. Otherwise, act as specified by `Some(mode)`. + pub emit_collapsed: Option, } /// All information that is required to perform a dirwalk, and classify paths properly. @@ -162,6 +182,8 @@ pub struct Context<'a> { pub git_dir_realpath: &'a std::path::Path, /// The current working directory as returned by `gix_fs::current_dir()` to assure it respects `core.precomposeUnicode`. /// It's used to produce the realpath of the git-dir of a repository candidate to assure it's not our own repository. + /// + /// It is also used to assure that when the walk is for deletion, that the current working dir will not be collapsed. pub current_dir: &'a std::path::Path, /// The index to quickly understand if a file or directory is tracked or not. /// @@ -180,7 +202,7 @@ pub struct Context<'a> { /// in case-sensitive mode. It does, however, skip the directory hash creation (for looking /// up directories) unless `core.ignoreCase` is enabled. /// - /// We only use the hashmap when when available and when [`ignore_case`](Options::ignore_case) is enabled in the options. + /// We only use the hashmap when available and when [`ignore_case`](Options::ignore_case) is enabled in the options. pub ignore_case_index_lookup: Option<&'a gix_index::AccelerateLookup<'a>>, /// A pathspec to use as filter - we only traverse into directories if it matches. /// Note that the `ignore_case` setting it uses should match our [Options::ignore_case]. @@ -201,10 +223,19 @@ pub struct Context<'a> { pub excludes: Option<&'a mut gix_worktree::Stack>, /// Access to the object database for use with `excludes` - it's possible to access `.gitignore` files in the index if configured. pub objects: &'a dyn gix_object::Find, + /// If not `None`, override the traversal root that is computed and use this one instead. + /// + /// This can be useful if the traversal root may be a file, in which case the traversal will + /// still be returning possibly matching root entries. + /// + /// ### Panics + /// + /// If the `traversal_root` is not in the `worktree_root` passed to [walk()](crate::walk()). + pub explicit_traversal_root: Option<&'a std::path::Path>, } /// Additional information collected as outcome of [`walk()`](function::walk()). -#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] +#[derive(Default, Debug, Clone, Ord, PartialOrd, Eq, PartialEq)] pub struct Outcome { /// The amount of calls to read the directory contents. pub read_dir_calls: u32, @@ -222,8 +253,6 @@ pub enum Error { WorktreeRootIsFile { root: PathBuf }, #[error("Traversal root '{}' contains relative path components and could not be normalized", root.display())] NormalizeRoot { root: PathBuf }, - #[error("Traversal root '{}' must be literally contained in worktree root '{}'", root.display(), worktree_root.display())] - RootNotInWorktree { root: PathBuf, worktree_root: PathBuf }, #[error("A symlink was found at component {component_index} of traversal root '{}' as seen from worktree root '{}'", root.display(), worktree_root.display())] SymlinkInRoot { root: PathBuf, diff --git a/gix-dir/src/walk/readdir.rs b/gix-dir/src/walk/readdir.rs index be656a66081..64db84c58c2 100644 --- a/gix-dir/src/walk/readdir.rs +++ b/gix-dir/src/walk/readdir.rs @@ -1,11 +1,11 @@ use bstr::{BStr, BString, ByteSlice}; use std::borrow::Cow; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::entry::{PathspecMatch, Status}; use crate::walk::function::{can_recurse, emit_entry}; use crate::walk::EmissionMode::CollapseDirectory; -use crate::walk::{classify, Action, Context, Delegate, Error, Options, Outcome}; +use crate::walk::{classify, Action, CollapsedEntriesEmissionMode, Context, Delegate, Error, Options, Outcome}; use crate::{entry, walk, Entry}; /// ### Deviation @@ -13,7 +13,7 @@ use crate::{entry, walk, Entry}; /// Git mostly silently ignores IO errors and stops iterating seemingly quietly, while we error loudly. #[allow(clippy::too_many_arguments)] pub(super) fn recursive( - is_worktree_dir: bool, + may_collapse: bool, current: &mut PathBuf, current_bstr: &mut BString, current_info: classify::Outcome, @@ -30,7 +30,7 @@ pub(super) fn recursive( })?; let mut num_entries = 0; - let mark = state.mark(is_worktree_dir); + let mark = state.mark(may_collapse); let mut prevent_collapse = false; for entry in entries { let entry = entry.map_err(|err| Error::DirEntry { @@ -64,11 +64,21 @@ pub(super) fn recursive( )?; if can_recurse(current_bstr.as_bstr(), info, opts.for_deletion, delegate) { - let (action, subdir_prevent_collapse) = - recursive(false, current, current_bstr, info, ctx, opts, delegate, out, state)?; + 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 != Action::Continue { - break; + return Ok((action, prevent_collapse)); } } else 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); @@ -94,10 +104,11 @@ pub(super) fn recursive( Ok((res, prevent_collapse)) } -#[derive(Default)] 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 { @@ -118,18 +129,59 @@ impl State { } /// Keep track of state we need to later resolve the state. - /// Worktree directories are special, as they don't fold. - fn mark(&self, is_worktree_dir: bool) -> Mark { + /// Top-level directories are special, as they don't fold. + fn mark(&self, may_collapse: bool) -> Mark { Mark { start_index: self.on_hold.len(), - is_worktree_dir, + 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() + .map_or(true, |cwd| cwd != directory_to_traverse) + } + + pub(super) fn emit_remaining( + &mut self, + is_top_level: 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: is_top_level, + } + .emit_all_held(self, opts, out, delegate); + } } struct Mark { start_index: usize, - is_worktree_dir: bool, + may_collapse: bool, } impl Mark { @@ -211,7 +263,7 @@ impl Mark { ctx: &mut Context<'_>, delegate: &mut dyn walk::Delegate, ) -> Option { - if self.is_worktree_dir { + if !self.may_collapse { return None; } let (mut expendable, mut precious, mut untracked, mut entries, mut matching_entries) = (0, 0, 0, 0, 0); @@ -286,12 +338,21 @@ impl Mark { let mut removed_without_emitting = 0; let mut action = Action::Continue; for entry in state.on_hold.drain(self.start_index..) { - if entry.status != dir_status && action == Action::Continue { - let info = classify::Outcome::from(&entry); - action = emit_entry(Cow::Owned(entry.rela_path), info, Some(dir_status), opts, out, delegate); - } else { + if action != Action::Continue { 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; diff --git a/gix-dir/tests/dir_walk_cwd.rs b/gix-dir/tests/dir_walk_cwd.rs index afffaee423f..a05c35d28ca 100644 --- a/gix-dir/tests/dir_walk_cwd.rs +++ b/gix-dir/tests/dir_walk_cwd.rs @@ -1,7 +1,9 @@ -use crate::walk_utils::{collect, entry, fixture, options}; +use crate::walk_utils::{collect, entryps, fixture, options}; use gix_dir::entry::Kind::File; +use gix_dir::entry::PathspecMatch::Prefix; use gix_dir::entry::Status::Untracked; use gix_dir::walk; +use pretty_assertions::assert_eq; use std::path::Path; pub mod walk_utils; @@ -10,8 +12,9 @@ pub mod walk_utils; fn prefixes_work_as_expected() -> gix_testtools::Result { let root = fixture("only-untracked"); std::env::set_current_dir(root.join("d"))?; - let (out, entries) = collect(&root, |keep, ctx| { - walk(&Path::new("..").join("d"), Path::new(".."), ctx, options(), keep) + let troot = Path::new("..").join("d"); + let ((out, _root), entries) = collect(Path::new(".."), Some(&troot), |keep, ctx| { + walk(Path::new(".."), ctx, options(), keep) }); assert_eq!( out, @@ -24,9 +27,9 @@ fn prefixes_work_as_expected() -> gix_testtools::Result { assert_eq!( &entries, &[ - entry("d/a", Untracked, File), - entry("d/b", Untracked, File), - entry("d/d/a", Untracked, File), + entryps("d/a", Untracked, File, Prefix), + entryps("d/b", Untracked, File, Prefix), + entryps("d/d/a", Untracked, File, Prefix), ] ); Ok(()) diff --git a/gix-dir/tests/fixtures/generated-archives/.gitignore b/gix-dir/tests/fixtures/generated-archives/.gitignore index cd3b9cb9305..028ec685db4 100644 --- a/gix-dir/tests/fixtures/generated-archives/.gitignore +++ b/gix-dir/tests/fixtures/generated-archives/.gitignore @@ -1,3 +1,2 @@ -walk_baseline.tar.xz many.tar.xz many-symlinks.tar.xz \ No newline at end of file diff --git a/gix-dir/tests/fixtures/many.sh b/gix-dir/tests/fixtures/many.sh index 4923947f19f..4531eebaa55 100644 --- a/gix-dir/tests/fixtures/many.sh +++ b/gix-dir/tests/fixtures/many.sh @@ -318,3 +318,16 @@ cp -R type-mismatch-icase-clash-dir-is-file type-mismatch-icase-clash-file-is-di ) mkdir empty touch just-a-file + +git init submodule +(cd submodule + touch empty && git add empty + git commit -m upstream +) + +git clone submodule multiple-submodules +(cd multiple-submodules + git submodule add ../submodule submodule + git submodule add ../submodule a/b + git commit -m "add modules" +) diff --git a/gix-dir/tests/walk/mod.rs b/gix-dir/tests/walk/mod.rs index 49014f7003c..5cc87ab305b 100644 --- a/gix-dir/tests/walk/mod.rs +++ b/gix-dir/tests/walk/mod.rs @@ -1,13 +1,16 @@ -use gix_dir::walk; +use gix_dir::{walk, EntryRef}; use pretty_assertions::assert_eq; use crate::walk_utils::{ - collect, collect_filtered, entry, entry_dirstat, entry_nokind, entry_nomatch, entryps, entryps_dirstat, fixture, - fixture_in, options, options_emit_all, try_collect, try_collect_filtered_opts, EntryExt, Options, + collect, collect_filtered, collect_filtered_with_cwd, entry, entry_dirstat, entry_nokind, entry_nomatch, entryps, + entryps_dirstat, fixture, fixture_in, options, options_emit_all, try_collect, try_collect_filtered_opts, + try_collect_filtered_opts_collect, try_collect_filtered_opts_collect_with_root, EntryExt, Options, }; +use gix_dir::entry; use gix_dir::entry::Kind::*; use gix_dir::entry::PathspecMatch::*; use gix_dir::entry::Status::*; +use gix_dir::walk::CollapsedEntriesEmissionMode::{All, OnStatusMismatch}; use gix_dir::walk::EmissionMode::*; use gix_dir::walk::ForDeletionMode; use gix_ignore::Kind::*; @@ -21,9 +24,15 @@ fn root_may_not_lead_through_symlinks() -> crate::Result { ("breakout-symlink", "hide/../hide", 1), ] { let root = fixture_in("many-symlinks", name); - let err = try_collect(&root, |keep, ctx| { - walk(&root.join(intermediate).join("breakout"), &root, ctx, options(), keep) - }) + let troot = root.join(intermediate).join("breakout"); + let err = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options(), keep), + None::<&str>, + Default::default(), + ) .unwrap_err(); assert!( matches!(err, walk::Error::SymlinkInRoot { component_index, .. } if component_index == expected), @@ -36,7 +45,7 @@ fn root_may_not_lead_through_symlinks() -> crate::Result { #[test] fn empty_root() -> crate::Result { let root = fixture("empty"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -51,9 +60,8 @@ fn empty_root() -> crate::Result { "by default, nothing is shown as the directory is empty" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -71,10 +79,9 @@ fn empty_root() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("", Untracked, EmptyDirectory), + entries, + [entry("", Untracked, EmptyDirectory)], "this is how we can indicate the worktree is entirely untracked" ); Ok(()) @@ -83,7 +90,7 @@ fn empty_root() -> crate::Result { #[test] fn complex_empty() -> crate::Result { let root = fixture("complex-empty"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options_emit_all(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); assert_eq!( out, walk::Outcome { @@ -104,9 +111,8 @@ fn complex_empty() -> crate::Result { "we see each and every directory, and get it classified as empty as it's set to be emitted" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -130,9 +136,8 @@ fn complex_empty() -> crate::Result { "by default, no empty directory shows up" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -163,10 +168,293 @@ fn complex_empty() -> crate::Result { Ok(()) } +#[test] +fn only_untracked_with_cwd_handling() -> crate::Result { + let root = fixture("only-untracked"); + let ((out, _root), entries) = collect_filtered_with_cwd( + &root, + None, + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + for_deletion: Some(Default::default()), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + None::<&str>, + ); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 9, + } + ); + assert_eq!( + entries, + [ + entry("a", Untracked, File), + entry("b", Untracked, File), + entry("c", Untracked, File), + entry("d", Untracked, Directory), + ], + "the top-level is never collapsed, as our CWD is the worktree root" + ); + + let ((out, _root), entries) = collect_filtered_with_cwd( + &root, + Some(&root.join("d")), + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + for_deletion: Some(Default::default()), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + None::<&str>, + ); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + assert_eq!( + entries, + [entryps("d", Untracked, Directory, Prefix),], + "even if the traversal root is for deletion, unless the CWD is set it will be collapsed (no special cases)" + ); + + // Needs real-root to assure + let real_root = gix_path::realpath(&root)?; + let ((out, _root), entries) = collect_filtered_with_cwd( + &real_root, + Some(&real_root), + Some("d"), + |keep, ctx| { + walk( + &real_root, + ctx, + walk::Options { + for_deletion: Some(Default::default()), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + None::<&str>, + ); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 8, + } + ); + assert_eq!( + entries, + [ + entry("a", Untracked, File), + entry("b", Untracked, File), + entry("c", Untracked, File), + entry("d/a", Untracked, File), + entry("d/b", Untracked, File), + entry("d/d", Untracked, Directory), + ], + "the traversal starts from the top, but we automatically prevent the 'd' directory from being deleted by stopping its collapse." + ); + + let ((out, _root), entries) = collect_filtered_with_cwd( + &real_root, + None, + Some("d"), + |keep, ctx| { + walk( + &real_root, + ctx, + walk::Options { + for_deletion: Some(Default::default()), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + Some("../d"), + ); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 8, + } + ); + assert_eq!( + entries, + [ + entryps("d/a", Untracked, File, Prefix), + entryps("d/b", Untracked, File, Prefix), + entryps("d/d", Untracked, Directory, Prefix), + ], + "this will correctly detect that the pathspec leads back into our CWD, which wouldn't be the case otherwise" + ); + + Ok(()) +} + +#[test] +fn only_untracked_with_pathspec() -> crate::Result { + let root = fixture("only-untracked"); + let ((out, _root), entries) = collect_filtered( + &root, + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + for_deletion: Some(Default::default()), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + Some("d/"), + ); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + assert_eq!( + entries, + [entryps("d", Untracked, Directory, Prefix),], + "this is equivalent as if we use a prefix, as we end up starting the traversal from 'd'" + ); + + let ((out, _root), entries) = collect_filtered( + &root, + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + for_deletion: None, + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + Some("d/"), + ); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + assert_eq!( + entries, + [entryps("d", Untracked, Directory, Prefix)], + "When not deleting things, it's once again the same effect as with a prefix" + ); + Ok(()) +} + +#[test] +fn only_untracked_with_prefix_deletion() -> crate::Result { + let root = fixture("only-untracked"); + let troot = root.join("d"); + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + for_deletion: Some(Default::default()), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + assert_eq!( + entries, + [entryps("d", Untracked, Directory, Prefix),], + "This is like being inside of 'd', but the CWD is now explicit so we happily fold" + ); + + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + for_deletion: None, + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + assert_eq!( + entries, + [entryps("d", Untracked, Directory, Prefix)], + "However, when not deleting, we can collapse, as we could still add all with 'git add .'" + ); + Ok(()) +} + #[test] fn only_untracked() -> crate::Result { let root = fixture("only-untracked"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -176,8 +464,8 @@ fn only_untracked() -> crate::Result { } ); assert_eq!( - &entries, - &[ + entries, + [ entry("a", Untracked, File), entry("b", Untracked, File), entry("c", Untracked, File), @@ -187,27 +475,27 @@ fn only_untracked() -> crate::Result { ] ); - let (out, entries) = collect_filtered(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep), Some("d/*")); + let ((out, _root), entries) = + collect_filtered(&root, None, |keep, ctx| walk(&root, ctx, options(), keep), Some("d/*")); assert_eq!( out, walk::Outcome { - read_dir_calls: 3, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 7, + seen_entries: 3, } ); assert_eq!( - &entries, - &[ + entries, + [ entryps("d/a", Untracked, File, WildcardMatch), entryps("d/b", Untracked, File, WildcardMatch), entryps("d/d/a", Untracked, File, WildcardMatch), ] ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -227,8 +515,8 @@ fn only_untracked() -> crate::Result { "There are 2 extra directories that we fold into, but ultimately discard" ); assert_eq!( - &entries, - &[ + entries, + [ entry("a", Untracked, File), entry("b", Untracked, File), entry("c", Untracked, File), @@ -241,11 +529,11 @@ fn only_untracked() -> crate::Result { #[test] fn only_untracked_explicit_pathspec_selection() -> crate::Result { let root = fixture("only-untracked"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -260,25 +548,25 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { assert_eq!( out, walk::Outcome { - read_dir_calls: 3, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 7, + seen_entries: 3, }, ); assert_eq!( - &entries, - &[ + entries, + [ entryps("d/a", Untracked, File, Verbatim), entryps("d/d/a", Untracked, File, Verbatim) ], "this works just like expected, as nothing is collapsed anyway" ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -294,19 +582,15 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { assert_eq!( out, walk::Outcome { - read_dir_calls: 3, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 7, + seen_entries: 3, }, "no collapsing happens" ); assert_eq!( - &entries, - &[ - entry_nokind(".git", DotGit), - entry_nokind("a", Pruned), - entry_nokind("b", Pruned), - entry_nokind("c", Pruned), + entries, + [ entryps("d/a", Untracked, File, Verbatim), entry_nokind("d/b", Pruned), entryps("d/d/a", Untracked, File, Verbatim)], @@ -314,11 +598,11 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { while preventing the directory collapse from happening" ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -333,16 +617,16 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { assert_eq!( out, walk::Outcome { - read_dir_calls: 3, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 7 + 2, + seen_entries: 2 + 3, }, "collapsing happens just like Git" ); assert_eq!( - &entries, - &[entryps("d", Untracked, Directory, WildcardMatch)], - "wildcard matches allow collapsing directories because Git does" + entries, + [entryps("d", Untracked, Directory, WildcardMatch),], + "wildcard matches on the top-level without deletion show just the top level" ); Ok(()) } @@ -350,7 +634,7 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { #[test] fn expendable_and_precious() { let root = fixture("expendable-and-precious"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options_emit_all(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); assert_eq!( out, walk::Outcome { @@ -360,8 +644,8 @@ fn expendable_and_precious() { } ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry(".gitignore", Tracked, File), entry("a.o", Ignored(Expendable), File), @@ -384,9 +668,8 @@ fn expendable_and_precious() { "listing everything is a 'matching' preset, which is among the most efficient." ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -407,8 +690,8 @@ fn expendable_and_precious() { ); assert_eq!( - &entries, - &[ + entries, + [ entry(".gitignore", Tracked, File), entry("a.o", Ignored(Expendable), File), entry("all-expendable", Ignored(Expendable), Directory), @@ -429,9 +712,8 @@ fn expendable_and_precious() { those with all files of one type will be collapsed though" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -453,8 +735,8 @@ fn expendable_and_precious() { ); assert_eq!( - &entries, - &[ + entries, + [ entry("some-expendable/new", Untracked, File), entry("some-precious/new", Untracked, File), ], @@ -465,7 +747,7 @@ fn expendable_and_precious() { #[test] fn subdir_untracked() -> crate::Result { let root = fixture("subdir-untracked"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -474,13 +756,17 @@ fn subdir_untracked() -> crate::Result { seen_entries: 7, } ); - assert_eq!(&entries, &[entry("d/d/a", Untracked, File)]); + assert_eq!(entries, [entry("d/d/a", Untracked, File)]); - let (out, entries) = collect_filtered( + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( &root, - |keep, ctx| walk(&root, &root, ctx, options(), keep), + None, + Some(&root), + |keep, ctx| walk(&root, ctx, options(), keep), Some("d/d/*"), - ); + Default::default(), + )?; + assert_eq!(actual_root, root); assert_eq!( out, walk::Outcome { @@ -490,11 +776,10 @@ fn subdir_untracked() -> crate::Result { }, "pruning has no actual effect here as there is no extra directories that could be avoided" ); - assert_eq!(&entries, &[entryps("d/d/a", Untracked, File, WildcardMatch)]); + assert_eq!(entries, &[entryps("d/d/a", Untracked, File, WildcardMatch)]); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -513,16 +798,15 @@ fn subdir_untracked() -> crate::Result { }, "there is a folded directory we added" ); - assert_eq!(&entries, &[entry("d/d", Untracked, Directory)]); + assert_eq!(entries, [entry("d/d", Untracked, Directory)]); Ok(()) } #[test] fn only_untracked_from_subdir() -> crate::Result { let root = fixture("only-untracked"); - let (out, entries) = collect(&root, |keep, ctx| { - walk(&root.join("d").join("d"), &root, ctx, options(), keep) - }); + let troot = root.join("d").join("d"); + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -532,8 +816,8 @@ fn only_untracked_from_subdir() -> crate::Result { } ); assert_eq!( - &entries, - &[entry("d/d/a", Untracked, File)], + entries, + [entryps("d/d/a", Untracked, File, Prefix)], "even from subdirs, paths are worktree relative" ); Ok(()) @@ -543,11 +827,11 @@ fn only_untracked_from_subdir() -> crate::Result { fn untracked_and_ignored_pathspec_guidance() -> crate::Result { for for_deletion in [None, Some(Default::default())] { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -563,14 +847,15 @@ fn untracked_and_ignored_pathspec_guidance() -> crate::Result { assert_eq!( out, walk::Outcome { - read_dir_calls: 4, + read_dir_calls: 1, returned_entries: entries.len(), - seen_entries: 19, + seen_entries: 1, }, + "we have to read the parent directory, just like git, as we can't assume a directory" ); assert_eq!( - &entries, - &[entryps("d/d/generated/b", Ignored(Expendable), File, Verbatim)], + entries, + [entryps("d/d/generated/b", Ignored(Expendable), File, Verbatim)], "pathspecs allow reaching into otherwise ignored directories, ignoring the flag to collapse" ); } @@ -580,11 +865,11 @@ fn untracked_and_ignored_pathspec_guidance() -> crate::Result { #[test] fn untracked_and_ignored_for_deletion_negative_wildcard_spec() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -608,8 +893,8 @@ fn untracked_and_ignored_for_deletion_negative_wildcard_spec() -> crate::Result }, ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry(".gitignore", Untracked, File), entry("a.o", Ignored(Expendable), File), @@ -635,11 +920,11 @@ fn untracked_and_ignored_for_deletion_negative_wildcard_spec() -> crate::Result #[test] fn untracked_and_ignored_for_deletion_positive_wildcard_spec() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -663,8 +948,8 @@ fn untracked_and_ignored_for_deletion_positive_wildcard_spec() -> crate::Result }, ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry_nomatch(".gitignore", Pruned, File), entry_nomatch("a.o", Ignored(Expendable), File), @@ -688,11 +973,11 @@ fn untracked_and_ignored_for_deletion_positive_wildcard_spec() -> crate::Result #[test] fn untracked_and_ignored_for_deletion_nonmatching_wildcard_spec() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -716,8 +1001,8 @@ fn untracked_and_ignored_for_deletion_nonmatching_wildcard_spec() -> crate::Resu }, ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry_nomatch(".gitignore", Pruned, File), entry_nomatch("a.o", Ignored(Expendable), File), @@ -741,9 +1026,9 @@ fn nested_ignored_dirs_for_deletion_nonmatching_wildcard_spec() -> crate::Result let root = fixture("ignored-dir-nested-minimal"); let (_out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -761,16 +1046,16 @@ fn nested_ignored_dirs_for_deletion_nonmatching_wildcard_spec() -> crate::Result // NOTE: do not use `_out` as `.git` directory contents can change, it's controlled by Git, causing flakiness. assert_eq!( - &entries, - &[], + entries, + [], "it figures out that nothing actually matches, even though it has to check everything" ); let (_out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -788,8 +1073,8 @@ fn nested_ignored_dirs_for_deletion_nonmatching_wildcard_spec() -> crate::Result // NOTE: do not use `_out` as `.git` directory contents can change, it's controlled by Git, causing flakiness. assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry_nomatch(".gitignore", Pruned, File), entry_nomatch("bare/HEAD", Pruned, File), @@ -806,9 +1091,8 @@ fn nested_ignored_dirs_for_deletion_nonmatching_wildcard_spec() -> crate::Result #[test] fn expendable_and_precious_in_ignored_dir_with_pathspec() -> crate::Result { let root = fixture("expendable-and-precious-nested-in-ignored-dir"); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -832,8 +1116,8 @@ fn expendable_and_precious_in_ignored_dir_with_pathspec() -> crate::Result { ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry(".gitignore", Tracked, File).with_index_kind(File), entry("ignored", Ignored(Expendable), Directory), @@ -845,11 +1129,11 @@ fn expendable_and_precious_in_ignored_dir_with_pathspec() -> crate::Result { the next time we run." ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -874,8 +1158,8 @@ fn expendable_and_precious_in_ignored_dir_with_pathspec() -> crate::Result { ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry_nokind("ignored/d/.git", DotGit), entryps("ignored/d/.gitignore", Ignored(Expendable), File, WildcardMatch), @@ -903,9 +1187,8 @@ fn expendable_and_precious_in_ignored_dir_with_pathspec() -> crate::Result { #[test] fn untracked_and_ignored() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -925,8 +1208,8 @@ fn untracked_and_ignored() -> crate::Result { "some untracked ones are hidden by default" ); assert_eq!( - &entries, - &[ + entries, + [ entry(".gitignore", Untracked, File), entry("a.o", Ignored(Expendable), File), entry("b.o", Ignored(Expendable), File), @@ -945,11 +1228,11 @@ fn untracked_and_ignored() -> crate::Result { ] ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -972,8 +1255,8 @@ fn untracked_and_ignored() -> crate::Result { ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry_nomatch(".gitignore", Pruned, File), entryps("d/d/a", Untracked, File, WildcardMatch), @@ -981,9 +1264,8 @@ fn untracked_and_ignored() -> crate::Result { "…but with different classification as the ignore file is pruned so it's not untracked anymore" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1004,14 +1286,13 @@ fn untracked_and_ignored() -> crate::Result { "we still encounter the same amount of entries, and 1 folded directory" ); assert_eq!( - &entries, - &[entry(".gitignore", Untracked, File), entry("d/d", Untracked, Directory)], + entries, + [entry(".gitignore", Untracked, File), entry("d/d", Untracked, Directory)], "aggregation kicks in here" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1031,8 +1312,8 @@ fn untracked_and_ignored() -> crate::Result { "some untracked ones are hidden by default, folded directories" ); assert_eq!( - &entries, - &[ + entries, + [ entry(".gitignore", Untracked, File), entry("a.o", Ignored(Expendable), File), entry("b.o", Ignored(Expendable), File), @@ -1050,14 +1331,14 @@ fn untracked_and_ignored() -> crate::Result { "objects are aggregated" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1073,8 +1354,8 @@ fn untracked_and_ignored() -> crate::Result { "some untracked ones are hidden by default, and folded directories" ); assert_eq!( - &entries, - &[ + entries, + [ entry(".gitignore", Untracked, File), entry("a.o", Ignored(Expendable), File), entry("b.o", Ignored(Expendable), File), @@ -1098,53 +1379,149 @@ fn untracked_and_ignored() -> crate::Result { } #[test] -fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { +fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { + let root = fixture("subdir-untracked-and-ignored"); + let ((out, _root), entries) = collect_filtered( + &root, + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: None, + ..options() + }, + keep, + ) + }, + Some("d/d/b.o"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 1, + returned_entries: entries.len(), + seen_entries: 4, + }, + "it has to read 'd/d' as 'd/d/b.o' isn't a directory candidate" + ); + + assert_eq!( + entries, + [entryps("d/d/b.o", Ignored(Expendable), File, Verbatim)], + "when files are selected individually, they are never collapsed" + ); + + for (spec, pathspec_match) in [("d/d/*", WildcardMatch), ("d/d", Prefix), ("d/d/", Prefix)] { + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&root), + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: None, + emit_collapsed: Some(OnStatusMismatch), + ..options() + }, + keep, + ) + }, + Some(spec), + Default::default(), + )?; + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 21, + }, + ); + + assert_eq!( + entries, + [ + entryps("d/d", Untracked, Directory, pathspec_match), + entryps_dirstat("d/d/a.o", Ignored(Expendable), File, pathspec_match, Untracked), + entryps_dirstat("d/d/b.o", Ignored(Expendable), File, pathspec_match, Untracked), + entryps_dirstat( + "d/d/generated", + Ignored(Expendable), + Directory, + pathspec_match, + Untracked + ), + ], + "with wildcard matches, it's OK to collapse though" + ); + } + Ok(()) +} + +#[test] +fn untracked_and_ignored_collapse_handling_mixed_with_prefix() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: None, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, ) }, - Some("d/d/b.o"), + Some("d/d"), ); assert_eq!( out, walk::Outcome { read_dir_calls: 3, returned_entries: entries.len(), - seen_entries: 19, + seen_entries: 11 }, + "this is not a directory, so the prefix is only 'd', not 'd/d'" ); assert_eq!( - &entries, - &[entryps("d/d/b.o", Ignored(Expendable), File, Verbatim)], - "when files are selected individually, they are never collapsed" + entries, + [ + entryps("d/d", Untracked, Directory, Prefix), + entryps_dirstat("d/d/a.o", Ignored(Expendable), File, Prefix, Untracked), + entryps_dirstat("d/d/b.o", Ignored(Expendable), File, Prefix, Untracked), + entryps_dirstat("d/d/generated", Ignored(Expendable), Directory, Prefix, Untracked), + ], + "as it's not the top-level anymore (which is 'd', not 'd/d'), we will collapse" ); - for (spec, pathspec_match) in [("d/d/*", WildcardMatch), ("d/d", Prefix), ("d/d/", Prefix)] { - let (out, entries) = collect_filtered( + for (spec, pathspec_match) in [("d/d/*", WildcardMatch), ("d/d/", Prefix)] { + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: None, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1155,15 +1532,15 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { assert_eq!( out, walk::Outcome { - read_dir_calls: 4, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 21, + seen_entries: 6, }, ); assert_eq!( - &entries, - &[ + entries, + [ entryps("d/d", Untracked, Directory, pathspec_match), entryps_dirstat("d/d/a.o", Ignored(Expendable), File, pathspec_match, Untracked), entryps_dirstat("d/d/b.o", Ignored(Expendable), File, pathspec_match, Untracked), @@ -1175,20 +1552,21 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { Untracked ), ], - "with wildcard matches, it's OK to collapse though" + "{spec}: with wildcard matches, it's OK to collapse though" ); } + // TODO: try for deletion, and prefix combinations Ok(()) } #[test] fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1211,8 +1589,8 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crat }, ); assert_eq!( - &entries, - &[ + entries, + [ entryps("a.o", Ignored(Expendable), File, WildcardMatch), entryps("b.o", Ignored(Expendable), File, WildcardMatch), entryps("c.o", Ignored(Expendable), File, WildcardMatch), @@ -1229,17 +1607,18 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crat Thus we stick to the rule: if everything in the directory is going to be deleted, we delete the whole directory." ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1256,8 +1635,8 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crat }, ); assert_eq!( - &entries, - &[ + entries, + [ entryps(".gitignore", Untracked, File, WildcardMatch), entryps("a.o", Ignored(Expendable), File, WildcardMatch), entryps("b.o", Ignored(Expendable), File, WildcardMatch), @@ -1286,11 +1665,11 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crat #[test] fn untracked_and_ignored_collapse_handling_for_deletion_with_prefix_wildcards() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1307,14 +1686,14 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_prefix_wildcards() assert_eq!( out, walk::Outcome { - read_dir_calls: 2, + read_dir_calls: 1, returned_entries: entries.len(), - seen_entries: 12, + seen_entries: 2, }, ); assert_eq!( - &entries, - &[entryps("generated/a.o", Ignored(Expendable), File, WildcardMatch)], + entries, + [entryps("generated/a.o", Ignored(Expendable), File, WildcardMatch)], "this is the same result as '*.o', but limited to a subdirectory" ); Ok(()) @@ -1323,9 +1702,8 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_prefix_wildcards() #[test] fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1347,20 +1725,20 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result ); assert_eq!( - &entries, - &[entry(".gitignore", Untracked, File), entry("d/d/a", Untracked, File)], + entries, + [entry(".gitignore", Untracked, File), entry("d/d/a", Untracked, File)], "without ignored files, we only see untracked ones, without a chance to collapse. This actually is something Git fails to do." ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1376,8 +1754,8 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result ); assert_eq!( - &entries, - &[ + entries, + [ entry(".gitignore", Untracked, File), entry("a.o", Ignored(Expendable), File), entry("b.o", Ignored(Expendable), File), @@ -1395,17 +1773,18 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result "with ignored files, we can collapse untracked and ignored like before" ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1416,34 +1795,70 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result assert_eq!( out, walk::Outcome { - read_dir_calls: 4, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 21, + seen_entries: 6, }, ); assert_eq!( - &entries, - &[ + entries, + [ entryps("d/d", Untracked, Directory, WildcardMatch), entryps_dirstat("d/d/a.o", Ignored(Expendable), File, WildcardMatch, Untracked), entryps_dirstat("d/d/b.o", Ignored(Expendable), File, WildcardMatch, Untracked), - entryps_dirstat( - "d/d/generated", - Ignored(Expendable), - Directory, - WildcardMatch, - Untracked - ), + entryps_dirstat("d/d/generated", Ignored(Expendable), Directory, WildcardMatch, Untracked), + ], + "everything is filtered down to the pathspec, otherwise it's like before. Not how all-matching 'generated' collapses, \ + but also how 'd/d' collapses as our current working directory the worktree" + ); + + let real_root = gix_path::realpath(&root)?; + let ((out, _root), entries) = collect_filtered_with_cwd( + &real_root, + Some(&real_root), + Some("d/d"), + |keep, ctx| { + walk( + &real_root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), + ..options() + }, + keep, + ) + }, + Some("d/d/*"), // NOTE: this would be '*' in the real world and automatically prefixed, but the test-setup is limited + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 5, + }, + ); + + assert_eq!( + entries, + [ + entryps("d/d/a", Untracked, File, WildcardMatch), + entryps("d/d/a.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/d/b.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/d/generated", Ignored(Expendable), Directory, WildcardMatch), ], - "everything is filtered down to the pathspec, otherwise it's like before. Not how all-matching collapses" + "Now the CWD is 'd/d', which means we can't collapse it." ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1461,32 +1876,33 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result assert_eq!( out, walk::Outcome { - read_dir_calls: 4, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 20, + seen_entries: 5, }, ); assert_eq!( - &entries, - &[ + entries, + [ entryps("d/d/a.o", Ignored(Expendable), File, WildcardMatch), entryps("d/d/b.o", Ignored(Expendable), File, WildcardMatch), ], "If the wildcard doesn't match everything, it can't be collapsed" ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1497,28 +1913,29 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result assert_eq!( out, walk::Outcome { - read_dir_calls: 4, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 21, + seen_entries: 6, }, ); assert_eq!( - &entries, - &[ + entries, + [ entryps("d/d", Untracked, Directory, Prefix), entryps_dirstat("d/d/a.o", Ignored(Expendable), File, Prefix, Untracked), entryps_dirstat("d/d/b.o", Ignored(Expendable), File, Prefix, Untracked), entryps_dirstat("d/d/generated", Ignored(Expendable), Directory, Prefix, Untracked), ], - "a prefix match works similarly, while also listing the dropped content for good measure" + "Now the whole folder is matched and can collapse, as no CWD is set - the prefix-based root isn't special anymore \ + as it is not easily predictable, and has its own rules." ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1535,15 +1952,15 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result assert_eq!( out, walk::Outcome { - read_dir_calls: 4, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 19, + seen_entries: 4, }, ); assert_eq!( - &entries, - &[entryps("d/d/a", Untracked, File, Prefix)], + entries, + [entryps("d/d/a", Untracked, File, Prefix)], "a prefix match works similarly" ); Ok(()) @@ -1552,9 +1969,8 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result #[test] fn precious_are_not_expendable() { let root = fixture("untracked-and-precious"); - let (_out, entries) = collect(&root, |keep, ctx| { + let (_out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1566,8 +1982,8 @@ fn precious_are_not_expendable() { ) }); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry(".gitignore", Tracked, File), entry("a.o", Ignored(Expendable), File), @@ -1580,14 +1996,14 @@ fn precious_are_not_expendable() { ], "just to have an overview" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1603,8 +2019,8 @@ fn precious_are_not_expendable() { ); assert_eq!( - &entries, - &[ + entries, + [ entry("a.o", Ignored(Expendable), File), entry("d/a.o", Ignored(Expendable), File), entry("d/b.o", Ignored(Expendable), File), @@ -1616,17 +2032,57 @@ fn precious_are_not_expendable() { a collapsed precious file." ); - for (equivalent_pathspec, expected_match) in [("d/*", WildcardMatch), ("d/", Prefix), ("d", Prefix)] { - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( + &root, + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), + ..options() + }, + keep, + ) + }, + Some("d"), + ); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 10, + }, + "'d' is assumed to be a file, hence it's stripped to its base '', yielding one more call." + ); + + assert_eq!( + entries, + [ + entryps("d/a.o", Ignored(Expendable), File, Prefix), + entryps("d/b.o", Ignored(Expendable), File, Prefix), + entryps("d/d", Untracked, Directory, Prefix), + entryps_dirstat("d/d/a.precious", Ignored(Precious), File, Prefix, Untracked), + ], + "should yield the same entries - note how collapsed directories inherit the pathspec" + ); + for (equivalent_pathspec, expected_match) in [("d/*", WildcardMatch), ("d/", Prefix)] { + let ((out, _root), entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1637,16 +2093,16 @@ fn precious_are_not_expendable() { assert_eq!( out, walk::Outcome { - read_dir_calls: 3, + read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 10, + seen_entries: 7, }, - "{equivalent_pathspec}: should yield same result" + "{equivalent_pathspec}: should yield same result, they also see the 'd' prefix directory" ); assert_eq!( - &entries, - &[ + entries, + [ entryps("d/a.o", Ignored(Expendable), File, expected_match), entryps("d/b.o", Ignored(Expendable), File, expected_match), entryps("d/d", Untracked, Directory, expected_match), @@ -1656,9 +2112,8 @@ fn precious_are_not_expendable() { ); } - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1680,8 +2135,8 @@ fn precious_are_not_expendable() { ); assert_eq!( - &entries, - &[ + entries, + [ entry("a.o", Ignored(Expendable), File), entry("d/a.o", Ignored(Expendable), File), entry("d/b.o", Ignored(Expendable), File), @@ -1706,9 +2161,8 @@ fn decomposed_unicode_in_directory_is_returned_precomposed() -> crate::Result { std::fs::create_dir(root.path().join(decomposed))?; std::fs::write(root.path().join(decomposed).join(decomposed), [])?; - let (out, entries) = collect(root.path(), |keep, ctx| { + let ((out, _root), entries) = collect(root.path(), None, |keep, ctx| { walk( - root.path(), root.path(), ctx, walk::Options { @@ -1727,16 +2181,15 @@ fn decomposed_unicode_in_directory_is_returned_precomposed() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry(format!("{precomposed}/{precomposed}").as_str(), Untracked, File), + entries, + [entry(format!("{precomposed}/{precomposed}").as_str(), Untracked, File)], "even root paths are returned precomposed then" ); - let (_out, entries) = collect(root.path(), |keep, ctx| { + let troot = root.path().join(decomposed); + let ((out, _root), entries) = collect(root.path(), Some(&troot), |keep, ctx| { walk( - &root.path().join(decomposed), root.path(), ctx, walk::Options { @@ -1746,36 +2199,41 @@ fn decomposed_unicode_in_directory_is_returned_precomposed() -> crate::Result { keep, ) }); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry(format!("{decomposed}/{decomposed}").as_str(), Untracked, File), + out, + walk::Outcome { + read_dir_calls: 1, + returned_entries: entries.len(), + seen_entries: 1, + }, + "note how it starts directly in the right repository" + ); + assert_eq!( + entries, + [entryps( + format!("{decomposed}/{decomposed}").as_str(), + Untracked, + File, + Prefix + )], "if disabled, it stays decomposed as provided" ); Ok(()) } -#[test] -fn root_must_be_in_worktree() -> crate::Result { - let err = try_collect("worktree root does not matter here".as_ref(), |keep, ctx| { - walk( - "traversal".as_ref(), - "unrelated-worktree".as_ref(), - ctx, - options(), - keep, - ) - }) - .unwrap_err(); - assert!(matches!(err, walk::Error::RootNotInWorktree { .. })); - Ok(()) -} - #[test] #[cfg_attr(windows, ignore = "symlinks the way they are organized don't yet work on windows")] fn worktree_root_can_be_symlink() -> crate::Result { let root = fixture_in("many-symlinks", "symlink-to-breakout-symlink"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root.join("file"), &root, ctx, options(), keep)); + let troot = root.join("file"); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options(), keep), + None::<&str>, + Default::default(), + )?; assert_eq!( out, walk::Outcome { @@ -1784,10 +2242,9 @@ fn worktree_root_can_be_symlink() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("file", Untracked, File), + entries, + [entry("file", Untracked, File)], "it allows symlinks for the worktree itself" ); Ok(()) @@ -1797,14 +2254,9 @@ fn worktree_root_can_be_symlink() -> crate::Result { fn root_may_not_go_through_dot_git() -> crate::Result { let root = fixture("with-nested-dot-git"); for dir in ["", "subdir"] { - let (out, entries) = collect(&root, |keep, ctx| { - walk( - &root.join("dir").join(".git").join(dir), - &root, - ctx, - options_emit_all(), - keep, - ) + let troot = root.join("dir").join(".git").join(dir); + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| { + walk(&root, ctx, options_emit_all(), keep) }); assert_eq!( out, @@ -1814,8 +2266,11 @@ fn root_may_not_go_through_dot_git() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1, "no traversal happened as root passes though .git"); - assert_eq!(&entries[0], &entry_nomatch("dir/.git", DotGit, Directory)); + assert_eq!( + entries, + [entry_nomatch("dir/.git", DotGit, Directory)], + "no traversal happened as root passes though .git" + ); } Ok(()) } @@ -1823,11 +2278,13 @@ fn root_may_not_go_through_dot_git() -> crate::Result { #[test] fn root_enters_directory_with_dot_git_in_reconfigured_worktree_tracked() -> crate::Result { let root = fixture("nonstandard-worktree"); - let (out, entries) = try_collect_filtered_opts( + let troot = root.join("dir-with-dot-git").join("inside"); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, + None, + Some(&troot), |keep, ctx| { walk( - &root.join("dir-with-dot-git").join("inside"), &root, ctx, walk::Options { @@ -1850,18 +2307,18 @@ fn root_enters_directory_with_dot_git_in_reconfigured_worktree_tracked() -> crat } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("dir-with-dot-git/inside", Tracked, File), + entries, + [entry("dir-with-dot-git/inside", Tracked, File)], "everything is tracked, so it won't try to detect git repositories anyway" ); - let (out, entries) = try_collect_filtered_opts( + let troot = root.join("dir-with-dot-git").join("inside"); + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, + Some(&troot), |keep, ctx| { walk( - &root.join("dir-with-dot-git").join("inside"), &root, ctx, walk::Options { @@ -1891,24 +2348,18 @@ fn root_enters_directory_with_dot_git_in_reconfigured_worktree_tracked() -> crat #[test] fn root_enters_directory_with_dot_git_in_reconfigured_worktree_untracked() -> crate::Result { let root = fixture("nonstandard-worktree-untracked"); - let (_out, entries) = try_collect_filtered_opts( + let troot = root.join("dir-with-dot-git").join("inside"); + let (_out, entries) = try_collect_filtered_opts_collect_with_root( &root, - |keep, ctx| { - walk( - &root.join("dir-with-dot-git").join("inside"), - &root, - ctx, - options(), - keep, - ) - }, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options(), keep), None::<&str>, Options::git_dir("dir-with-dot-git/.git"), )?; - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("dir-with-dot-git/inside", Untracked, File), + entries, + [entry("dir-with-dot-git/inside", Untracked, File)], "it can enter a dir and treat it as normal even if own .git is inside,\ which otherwise would be a repository" ); @@ -1918,27 +2369,39 @@ fn root_enters_directory_with_dot_git_in_reconfigured_worktree_untracked() -> cr #[test] fn root_may_not_go_through_nested_repository_unless_enabled() -> crate::Result { let root = fixture("nested-repository"); - let walk_root = root.join("nested").join("file"); - let (_out, entries) = collect(&root, |keep, ctx| { - walk( - &walk_root, - &root, - ctx, - walk::Options { - recurse_repositories: true, - ..options() - }, - keep, - ) - }); - assert_eq!(entries.len(), 1); + let troot = root.join("nested").join("file"); + let (_out, entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + recurse_repositories: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + Default::default(), + )?; assert_eq!( - &entries[0], - &entry("nested/file", Untracked, File), + entries, + [entry("nested/file", Untracked, File)], "it happily enters the repository and lists the file" ); - let (out, entries) = collect(&root, |keep, ctx| walk(&walk_root, &root, ctx, options(), keep)); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options(), keep), + None::<&str>, + Default::default(), + )?; assert_eq!( out, walk::Outcome { @@ -1947,10 +2410,9 @@ fn root_may_not_go_through_nested_repository_unless_enabled() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("nested", Untracked, Repository), + entries, + [entry("nested", Untracked, Repository)], "thus it ends in the directory that is a repository" ); Ok(()) @@ -1959,28 +2421,27 @@ fn root_may_not_go_through_nested_repository_unless_enabled() -> crate::Result { #[test] fn root_may_not_go_through_submodule() -> crate::Result { let root = fixture("with-submodule"); - let (out, entries) = collect(&root, |keep, ctx| { - walk( - &root.join("submodule").join("dir").join("file"), - &root, - ctx, - options_emit_all(), - keep, - ) - }); + let troot = root.join("submodule").join("dir").join("file"); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options_emit_all(), keep), + None::<&str>, + Default::default(), + )?; assert_eq!( out, walk::Outcome { read_dir_calls: 0, returned_entries: entries.len(), seen_entries: 1, - } + }, ); - assert_eq!(entries.len(), 1, "it refuses to start traversal in a submodule"); assert_eq!( - &entries[0], - &entry("submodule", Tracked, Repository), - "thus it ends in the directory that is the submodule" + entries, + [entry("submodule", Tracked, Repository)], + "it refuses to start traversal in a submodule, thus it ends in the directory that is the submodule" ); Ok(()) } @@ -1988,7 +2449,7 @@ fn root_may_not_go_through_submodule() -> crate::Result { #[test] fn walk_with_submodule() -> crate::Result { let root = fixture("with-submodule"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options_emit_all(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); assert_eq!( out, walk::Outcome { @@ -2013,9 +2474,15 @@ fn walk_with_submodule() -> crate::Result { #[test] fn root_that_is_tracked_file_is_returned() -> crate::Result { let root = fixture("dir-with-tracked-file"); - let (out, entries) = collect(&root, |keep, ctx| { - walk(&root.join("dir").join("file"), &root, ctx, options_emit_all(), keep) - }); + let troot = &root.join("dir").join("file"); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(troot), + |keep, ctx| walk(&root, ctx, options_emit_all(), keep), + None::<&str>, + Default::default(), + )?; assert_eq!( out, walk::Outcome { @@ -2025,10 +2492,9 @@ fn root_that_is_tracked_file_is_returned() -> crate::Result { } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("dir/file", Tracked, File), + entries, + [entry("dir/file", Tracked, File)], "a tracked file as root just returns that file (even though no iteration is possible)" ); Ok(()) @@ -2037,9 +2503,15 @@ fn root_that_is_tracked_file_is_returned() -> crate::Result { #[test] fn root_that_is_untracked_file_is_returned() -> crate::Result { let root = fixture("dir-with-file"); - let (out, entries) = collect(&root, |keep, ctx| { - walk(&root.join("dir").join("file"), &root, ctx, options(), keep) - }); + let troot = root.join("dir").join("file"); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options(), keep), + None::<&str>, + Default::default(), + )?; assert_eq!( out, walk::Outcome { @@ -2049,10 +2521,9 @@ fn root_that_is_untracked_file_is_returned() -> crate::Result { } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("dir/file", Untracked, File), + entries, + [entry("dir/file", Untracked, File)], "an untracked file as root just returns that file (even though no iteration is possible)" ); Ok(()) @@ -2061,18 +2532,22 @@ fn root_that_is_untracked_file_is_returned() -> crate::Result { #[test] fn top_level_root_that_is_a_file() { let root = fixture("just-a-file"); - let err = try_collect(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep)).unwrap_err(); + let err = try_collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)).unwrap_err(); assert!(matches!(err, walk::Error::WorktreeRootIsFile { .. })); } #[test] fn root_can_be_pruned_early_with_pathspec() -> crate::Result { let root = fixture("dir-with-file"); - let (out, entries) = collect_filtered( + let troot = root.join("dir"); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, - |keep, ctx| walk(&root.join("dir"), &root, ctx, options_emit_all(), keep), + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options_emit_all(), keep), Some("no-match/"), - ); + Default::default(), + )?; assert_eq!( out, walk::Outcome { @@ -2081,37 +2556,168 @@ fn root_can_be_pruned_early_with_pathspec() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry_nomatch("dir", Pruned, Directory), + entries, + [entry_nomatch("dir", Pruned, Directory)], "the pathspec didn't match the root, early abort" ); Ok(()) } +#[test] +fn submodules() -> crate::Result { + let root = fixture("multiple-submodules"); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + let expected_content = [ + entry_nokind(".git", DotGit), + entry(".gitmodules", Tracked, File).with_index_kind(File), + entry("a/b", Tracked, Repository).with_index_kind(Repository), + entry("empty", Tracked, File).with_index_kind(File), + entry("submodule", Tracked, Repository).with_index_kind(Repository), + ]; + assert_eq!(entries, expected_content, "submodules are detected as repositories"); + + let ((out1, _root), entries) = try_collect_filtered_opts_collect( + &root, + None, + |keep, ctx| walk(&root, ctx, options_emit_all(), keep), + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + )?; + assert_eq!(out1, out, "the output matches precisely"); + assert_eq!( + entries, expected_content, + "this is also the case if the index isn't considered fresh" + ); + + let ((out2, _root), entries) = try_collect_filtered_opts_collect( + &root, + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + ignore_case: true, + ..options_emit_all() + }, + keep, + ) + }, + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + )?; + assert_eq!(out2, out, "the output matches precisely, even with ignore-case"); + assert_eq!( + entries, expected_content, + "ignore case doesn't change anything (even though our search is quite different)" + ); + Ok(()) +} + +#[test] +fn cancel_with_collection_does_not_fail() -> crate::Result { + struct CancelDelegate { + emits_left_until_cancel: usize, + } + + impl gix_dir::walk::Delegate for CancelDelegate { + fn emit(&mut self, _entry: EntryRef<'_>, _collapsed_directory_status: Option) -> walk::Action { + if self.emits_left_until_cancel == 0 { + walk::Action::Cancel + } else { + self.emits_left_until_cancel -= 1; + walk::Action::Continue + } + } + } + + for (idx, fixture_name) in [ + "nonstandard-worktree", + "nonstandard-worktree-untracked", + "dir-with-file", + "expendable-and-precious", + "subdir-untracked-and-ignored", + "empty-and-untracked-dir", + "complex-empty", + "type-mismatch-icase-clash-file-is-dir", + ] + .into_iter() + .enumerate() + { + let root = fixture(fixture_name); + let mut dlg = CancelDelegate { + emits_left_until_cancel: idx, + }; + let _out = try_collect_filtered_opts( + &root, + None, + None, + None, + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + emit_ignored: Some(CollapseDirectory), + emit_empty_directories: true, + emit_tracked: true, + for_deletion: Some(Default::default()), + emit_pruned: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + &mut dlg, + Options::default(), + )?; + // Note that this also doesn't trigger an error - the caller has to deal with that. + } + Ok(()) +} + #[test] fn file_root_is_shown_if_pathspec_matches_exactly() -> crate::Result { let root = fixture("dir-with-file"); - let (out, entries) = collect_filtered( + let troot = root.join("dir").join("file"); + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, - |keep, ctx| walk(&root.join("dir").join("file"), &root, ctx, options(), keep), + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options(), keep), Some("*dir/*"), - ); + Default::default(), + )?; assert_eq!( out, walk::Outcome { read_dir_calls: 0, returned_entries: entries.len(), seen_entries: 1, - } + }, ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entryps("dir/file", Untracked, File, WildcardMatch), + entries, + [entryps("dir/file", Untracked, File, WildcardMatch)], "the pathspec matched the root precisely" ); Ok(()) @@ -2121,9 +2727,16 @@ fn file_root_is_shown_if_pathspec_matches_exactly() -> crate::Result { fn root_that_is_tracked_and_ignored_is_considered_tracked() -> crate::Result { let root = fixture("tracked-is-ignored"); let walk_root = "dir/file"; - let (out, entries) = collect(&root, |keep, ctx| { - walk(&root.join(walk_root), &root, ctx, options_emit_all(), keep) - }); + let troot = root.join(walk_root); + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options_emit_all(), keep), + None::<&str>, + Default::default(), + )?; + assert_eq!(actual_root, troot, "it uses the root we provide"); assert_eq!( out, walk::Outcome { @@ -2132,11 +2745,10 @@ fn root_that_is_tracked_and_ignored_is_considered_tracked() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry(walk_root, Tracked, File), + entries, + [entry(walk_root, Tracked, File)], "tracking is checked first, so we can safe exclude checks for most entries" ); Ok(()) @@ -2146,9 +2758,8 @@ fn root_that_is_tracked_and_ignored_is_considered_tracked() -> crate::Result { fn root_with_dir_that_is_tracked_and_ignored() -> crate::Result { let root = fixture("tracked-is-ignored"); for emission in [Matching, CollapseDirectory] { - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2168,7 +2779,6 @@ fn root_with_dir_that_is_tracked_and_ignored() -> crate::Result { seen_entries: 3, } ); - assert_eq!(entries.len(), 3); assert_eq!( entries, @@ -2189,9 +2799,8 @@ fn root_with_dir_that_is_tracked_and_ignored() -> crate::Result { fn empty_and_nested_untracked() -> crate::Result { let root = fixture("empty-and-untracked-dir"); for for_deletion in [None, Some(Default::default())] { - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2220,9 +2829,8 @@ fn empty_and_nested_untracked() -> crate::Result { ], "we find all untracked entries, no matter the deletion mode" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2259,19 +2867,27 @@ fn empty_and_nested_untracked() -> crate::Result { fn root_that_is_ignored_is_listed_for_files_and_directories() -> crate::Result { let root = fixture("ignored-dir"); for walk_root in ["dir", "dir/file"] { + let troot = root.join(walk_root); for emission in [Matching, CollapseDirectory] { - let (out, entries) = collect(&root, |keep, ctx| { - walk( - &root.join(walk_root), - &root, - ctx, - walk::Options { - emit_ignored: Some(emission), - ..options() - }, - keep, - ) - }); + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + emit_ignored: Some(emission), + ..options() + }, + keep, + ) + }, + None::<&str>, + Default::default(), + )?; + assert_eq!(actual_root, troot); assert_eq!( out, walk::Outcome { @@ -2280,11 +2896,10 @@ fn root_that_is_ignored_is_listed_for_files_and_directories() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry("dir", Ignored(Expendable), Directory), + entries, + [entry("dir", Ignored(Expendable), Directory)], "excluded directories or files that walkdir are listed without further recursion" ); } @@ -2295,9 +2910,8 @@ fn root_that_is_ignored_is_listed_for_files_and_directories() -> crate::Result { #[test] fn nested_bare_repos_in_ignored_directories() -> crate::Result { let root = fixture("ignored-dir-with-nested-bare-repository"); - let (_out, entries) = collect(&root, |keep, ctx| { + let (_out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2322,9 +2936,8 @@ fn nested_bare_repos_in_ignored_directories() -> crate::Result { Note the nested bare repository isn't seen, while the bare repository is just collapsed, and not detected as repository" ); - let (_out, entries) = collect(&root, |keep, ctx| { + let (_out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2347,9 +2960,8 @@ fn nested_bare_repos_in_ignored_directories() -> crate::Result { "When looking for non-bare repositories, we won't find bare ones, they just disappear as ignored collapsed directories" ); - let (_out, entries) = collect(&root, |keep, ctx| { + let (_out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2378,9 +2990,8 @@ fn nested_bare_repos_in_ignored_directories() -> crate::Result { #[test] fn nested_repos_in_untracked_directories() -> crate::Result { let root = fixture("untracked-hidden-bare"); - let (_out, entries) = collect(&root, |keep, ctx| { + let (_out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2398,9 +3009,8 @@ fn nested_repos_in_untracked_directories() -> crate::Result { "by default, the subdir is collapsed and we don't see the contained repository as it doesn't get classified" ); - let (_out, entries) = collect(&root, |keep, ctx| { + let (_out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2427,9 +3037,8 @@ fn nested_repos_in_untracked_directories() -> crate::Result { #[test] fn nested_repos_in_ignored_directories() -> crate::Result { let root = fixture("ignored-dir-with-nested-repository"); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2459,9 +3068,8 @@ fn nested_repos_in_ignored_directories() -> crate::Result { "by default, only the directory is listed and recursion is stopped there, as it matches the ignore directives." ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2493,9 +3101,8 @@ fn nested_repos_in_ignored_directories() -> crate::Result { "in this mode, we will list repositories nested in ignored directories separately" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2541,19 +3148,27 @@ fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { let precomposed = "ä"; std::fs::write(root.path().join(decomposed), [])?; - let (out, entries) = collect(root.path(), |keep, ctx| { - walk( - &root.path().join(decomposed), - root.path(), - ctx, - walk::Options { - precompose_unicode: true, - ..options() - }, - keep, - ) - }); + let troot = root.path().join(decomposed); + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( + root.path(), + None, + Some(&troot), + |keep, ctx| { + walk( + root.path(), + ctx, + walk::Options { + precompose_unicode: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + Default::default(), + )?; + assert_eq!(actual_root, troot); assert_eq!( out, walk::Outcome { @@ -2562,29 +3177,35 @@ fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); assert_eq!( - &entries[0], - &entry(precomposed, Untracked, File), + entries, + [entry(precomposed, Untracked, File)], "even root paths are returned precomposed then" ); - let (_out, entries) = collect(root.path(), |keep, ctx| { - walk( - &root.path().join(decomposed), - root.path(), - ctx, - walk::Options { - precompose_unicode: false, - ..options() - }, - keep, - ) - }); - assert_eq!(entries.len(), 1); + let troot = root.path().join(decomposed); + let ((_out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( + root.path(), + None, + Some(&troot), + |keep, ctx| { + walk( + root.path(), + ctx, + walk::Options { + precompose_unicode: false, + ..options() + }, + keep, + ) + }, + None::<&str>, + Default::default(), + )?; + assert_eq!(actual_root, troot); assert_eq!( - &entries[0], - &entry(decomposed, Untracked, File), + entries, + [entry(decomposed, Untracked, File)], "if disabled, it stays decomposed as provided" ); Ok(()) @@ -2593,9 +3214,8 @@ fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { #[test] fn untracked_and_ignored_collapse_mix() { let root = fixture("untracked-and-ignored-for-collapse"); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2627,14 +3247,14 @@ fn untracked_and_ignored_collapse_mix() { "ignored collapses separately from untracked" ); - let (out, entries) = collect(&root, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { emit_ignored: Some(Matching), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options_emit_all() }, keep, @@ -2660,15 +3280,51 @@ fn untracked_and_ignored_collapse_mix() { ], "untracked collapses separately from ignored, but note that matching directories are still emitted, i.e. ignored/" ); + + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + emit_ignored: Some(Matching), + emit_untracked: CollapseDirectory, + emit_collapsed: Some(All), + ..options_emit_all() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 8, + } + ); + assert_eq!( + entries, + [ + entry(".gitignore", Untracked, File), + entry("ignored", Ignored(Expendable), Directory), + entry("ignored-inside/d.o", Ignored(Expendable), File), + entry("mixed", Untracked, Directory), + entry_dirstat("mixed/c", Untracked, File, Untracked), + entry_dirstat("mixed/c.o", Ignored(Expendable), File, Untracked), + entry("untracked", Untracked, Directory), + entry_dirstat("untracked/a", Untracked, File, Untracked), + ], + "we can also emit all collapsed entries" + ); } #[test] -fn root_cannot_pass_through_case_altered_capital_dot_git_if_case_insensitive() { +fn root_cannot_pass_through_case_altered_capital_dot_git_if_case_insensitive() -> crate::Result { let root = fixture("with-nested-capitalized-dot-git"); for dir in ["", "subdir"] { - let (out, entries) = collect(&root, |keep, ctx| { + let troot = root.join("dir").join(".GIT").join(dir); + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| { walk( - &root.join("dir").join(".GIT").join(dir), &root, ctx, walk::Options { @@ -2686,32 +3342,39 @@ fn root_cannot_pass_through_case_altered_capital_dot_git_if_case_insensitive() { seen_entries: 1, } ); - assert_eq!(entries.len(), 1, "no traversal happened as root passes though .git"); assert_eq!( - &entries[0], - &entry_nomatch("dir/.GIT", DotGit, Directory), - "it compares in a case-insensitive fashion" + entries, + [entry_nomatch("dir/.GIT", DotGit, Directory)], + "no traversal happened as root passes though .git, it compares in a case-insensitive fashion" ); } - let (_out, entries) = collect(&root, |keep, ctx| { - walk( - &root.join("dir").join(".GIT").join("config"), - &root, - ctx, - walk::Options { - ignore_case: false, - ..options() - }, - keep, - ) - }); - assert_eq!(entries.len(), 1,); + let troot = root.join("dir").join(".GIT").join("config"); + let ((_out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| { + walk( + &root, + ctx, + walk::Options { + ignore_case: false, + ..options() + }, + keep, + ) + }, + None::<&str>, + Default::default(), + )?; + assert_eq!(actual_root, troot); assert_eq!( - &entries[0], - &entry("dir/.GIT/config", Untracked, File), + entries, + [entry("dir/.GIT/config", Untracked, File)], "it passes right through what now seems like any other directory" ); + Ok(()) } #[test] @@ -2719,15 +3382,16 @@ fn partial_checkout_cone_and_non_one() -> crate::Result { for fixture_name in ["partial-checkout-cone-mode", "partial-checkout-non-cone"] { let root = fixture(fixture_name); let not_in_cone_but_created_locally_by_hand = "d/file-created-manually"; - let (out, entries) = collect(&root, |keep, ctx| { - walk( - &root.join(not_in_cone_but_created_locally_by_hand), - &root, - ctx, - options_emit_all(), - keep, - ) - }); + let troot = root.join(not_in_cone_but_created_locally_by_hand); + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( + &root, + None, + Some(&troot), + |keep, ctx| walk(&root, ctx, options_emit_all(), keep), + None::<&str>, + Default::default(), + )?; + assert_eq!(actual_root, troot); assert_eq!( out, walk::Outcome { @@ -2736,11 +3400,9 @@ fn partial_checkout_cone_and_non_one() -> crate::Result { seen_entries: 1, } ); - assert_eq!(entries.len(), 1); - assert_eq!( - &entries[0], - &entry("d", TrackedExcluded, Directory), + entries, + [entry("d", TrackedExcluded, Directory)], "{fixture_name}: we avoid entering excluded sparse-checkout directories even if they are present on disk,\ no matter with cone or without." ); @@ -2751,11 +3413,11 @@ fn partial_checkout_cone_and_non_one() -> crate::Result { #[test] fn type_mismatch() { let root = fixture("type-mismatch"); - let (out, entries) = try_collect_filtered_opts( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2781,7 +3443,6 @@ fn type_mismatch() { seen_entries: 3, } ); - assert_eq!(entries.len(), 2); assert_eq!( entries, @@ -2794,11 +3455,11 @@ fn type_mismatch() { The typechange is visible only when there is an entry in the index, of course" ); - let (out, entries) = try_collect_filtered_opts( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2824,7 +3485,6 @@ fn type_mismatch() { seen_entries: 3 + 1, } ); - assert_eq!(entries.len(), 2); assert_eq!( entries, @@ -2839,11 +3499,11 @@ fn type_mismatch() { #[test] fn type_mismatch_ignore_case() { let root = fixture("type-mismatch-icase"); - let (out, entries) = try_collect_filtered_opts( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2879,11 +3539,11 @@ fn type_mismatch_ignore_case() { "this is the same as in the non-icase version, which means that icase lookup works" ); - let (out, entries) = try_collect_filtered_opts( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2923,11 +3583,11 @@ fn type_mismatch_ignore_case() { #[test] fn type_mismatch_ignore_case_clash_dir_is_file() { let root = fixture("type-mismatch-icase-clash-dir-is-file"); - let (out, entries) = try_collect_filtered_opts( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2964,11 +3624,11 @@ fn type_mismatch_ignore_case_clash_dir_is_file() { #[test] fn type_mismatch_ignore_case_clash_file_is_dir() { let root = fixture("type-mismatch-icase-clash-file-is-dir"); - let (out, entries) = try_collect_filtered_opts( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { diff --git a/gix-dir/tests/walk_utils/mod.rs b/gix-dir/tests/walk_utils/mod.rs index 3f7fdde3395..aef8ef5bd3c 100644 --- a/gix-dir/tests/walk_utils/mod.rs +++ b/gix-dir/tests/walk_utils/mod.rs @@ -30,6 +30,7 @@ pub fn options_emit_all() -> walk::Options { emit_tracked: true, emit_untracked: walk::EmissionMode::Matching, emit_empty_directories: true, + emit_collapsed: None, } } @@ -144,40 +145,105 @@ impl EntryExt for (Entry, Option) { pub fn collect( worktree_root: &Path, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, -) -> (walk::Outcome, Entries) { - try_collect(worktree_root, cb).unwrap() + root: Option<&Path>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, +) -> ((walk::Outcome, PathBuf), Entries) { + try_collect(worktree_root, root, cb).unwrap() } pub fn collect_filtered( worktree_root: &Path, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + root: Option<&Path>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, patterns: impl IntoIterator>, -) -> (walk::Outcome, Entries) { - try_collect_filtered(worktree_root, cb, patterns).unwrap() +) -> ((walk::Outcome, PathBuf), Entries) { + try_collect_filtered(worktree_root, root, cb, patterns).unwrap() } pub fn try_collect( worktree_root: &Path, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, -) -> Result<(walk::Outcome, Entries), walk::Error> { - try_collect_filtered(worktree_root, cb, None::<&str>) + root: Option<&Path>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, +) -> Result<((walk::Outcome, PathBuf), Entries), walk::Error> { + try_collect_filtered(worktree_root, root, cb, None::<&str>) } pub fn try_collect_filtered( worktree_root: &Path, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + root: Option<&Path>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, patterns: impl IntoIterator>, -) -> Result<(walk::Outcome, Entries), walk::Error> { - try_collect_filtered_opts(worktree_root, cb, patterns, Default::default()) +) -> Result<((walk::Outcome, PathBuf), Entries), walk::Error> { + try_collect_filtered_opts_collect(worktree_root, root, cb, patterns, Default::default()) } +pub fn try_collect_filtered_opts_collect( + worktree_root: &Path, + root: Option<&Path>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, + patterns: impl IntoIterator>, + options: Options<'_>, +) -> Result<((walk::Outcome, PathBuf), Entries), walk::Error> { + let mut dlg = gix_dir::walk::delegate::Collect::default(); + let outcome = try_collect_filtered_opts(worktree_root, root, None, None, cb, patterns, &mut dlg, options)?; + Ok((outcome, dlg.into_entries_by_path())) +} + +pub fn try_collect_filtered_opts_collect_with_root( + worktree_root: &Path, + root: Option<&Path>, + explicit_traversal_root: Option<&Path>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, + patterns: impl IntoIterator>, + options: Options<'_>, +) -> Result<((walk::Outcome, PathBuf), Entries), walk::Error> { + let mut dlg = gix_dir::walk::delegate::Collect::default(); + let outcome = try_collect_filtered_opts( + worktree_root, + root, + explicit_traversal_root, + None, + cb, + patterns, + &mut dlg, + options, + )?; + Ok((outcome, dlg.into_entries_by_path())) +} + +pub fn collect_filtered_with_cwd( + worktree_root: &Path, + root: Option<&Path>, + cwd_suffix: Option<&str>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, + patterns: impl IntoIterator>, +) -> ((walk::Outcome, PathBuf), Entries) { + let mut dlg = gix_dir::walk::delegate::Collect::default(); + let outcome = try_collect_filtered_opts( + worktree_root, + root, + None, + cwd_suffix, + cb, + patterns, + &mut dlg, + Default::default(), + ) + .expect("success"); + (outcome, dlg.into_entries_by_path()) +} + +#[allow(clippy::too_many_arguments)] pub fn try_collect_filtered_opts( worktree_root: &Path, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + root: Option<&Path>, + explicit_traversal_root: Option<&Path>, + append_to_cwd: Option<&str>, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, patterns: impl IntoIterator>, + delegate: &mut dyn gix_dir::walk::Delegate, Options { fresh_index, git_dir }: Options<'_>, -) -> Result<(walk::Outcome, Entries), walk::Error> { +) -> Result<(walk::Outcome, PathBuf), walk::Error> { let git_dir = worktree_root.join(git_dir.unwrap_or(".git")); let mut index = std::fs::read(git_dir.join("index")).ok().map_or_else( || gix_index::State::new(gix_index::hash::Kind::Sha1), @@ -210,7 +276,8 @@ pub fn try_collect_filtered_opts( patterns.into_iter().map(|spec| { gix_pathspec::parse(spec.as_ref(), gix_pathspec::Defaults::default()).expect("tests use valid pattern") }), - None, + root.map(|root| root.strip_prefix(worktree_root).expect("root is within worktree root")) + .or_else(|| append_to_cwd.map(Path::new)), "we don't provide absolute pathspecs, thus need no worktree root".as_ref(), ) .expect("search creation can't fail"); @@ -227,12 +294,18 @@ pub fn try_collect_filtered_opts( index.path_backing(), ); - let cwd = gix_fs::current_dir(false).expect("valid cwd"); + let mut cwd = worktree_root.to_owned(); + if let Some(suffix) = append_to_cwd { + assert!( + worktree_root.is_absolute(), + "BUG: need absolute worktree root for CWD checks to work" + ); + cwd.push(suffix) + } let git_dir_realpath = gix_path::realpath_opts(&git_dir, &cwd, gix_path::realpath::MAX_SYMLINKS).unwrap(); - let mut dlg = gix_dir::walk::delegate::Collect::default(); let lookup = index.prepare_icase_backing(); - let outcome = cb( - &mut dlg, + cb( + delegate, walk::Context { git_dir_realpath: &git_dir_realpath, current_dir: &cwd, @@ -242,10 +315,9 @@ pub fn try_collect_filtered_opts( pathspec_attributes: &mut |_, _, _, _| panic!("we do not use pathspecs that require attributes access."), excludes: Some(&mut stack), objects: &gix_object::find::Never, + explicit_traversal_root, }, - )?; - - Ok((outcome, dlg.into_entries_by_path())) + ) } pub struct Options<'a> { diff --git a/gix-path/src/convert.rs b/gix-path/src/convert.rs index 261faa6d0cc..93ece558d8b 100644 --- a/gix-path/src/convert.rs +++ b/gix-path/src/convert.rs @@ -1,3 +1,4 @@ +use std::path::Component; use std::{ borrow::Cow, ffi::{OsStr, OsString}, @@ -288,3 +289,48 @@ pub fn normalize<'a>(path: Cow<'a, Path>, current_dir: &Path) -> Option(relative_path: &'a Path, prefix: &Path) -> Cow<'a, Path> { + if prefix.as_os_str().is_empty() { + return Cow::Borrowed(relative_path); + } + debug_assert!( + relative_path.components().all(|c| matches!(c, Component::Normal(_))), + "BUG: all input is expected to be normalized, but relative_path was not" + ); + debug_assert!( + prefix.components().all(|c| matches!(c, Component::Normal(_))), + "BUG: all input is expected to be normalized, but prefix was not" + ); + + let mut buf = PathBuf::new(); + let mut rpc = relative_path.components().peekable(); + let mut equal_thus_far = true; + for pcomp in prefix.components() { + if equal_thus_far { + if let (Component::Normal(pname), Some(Component::Normal(rpname))) = (pcomp, rpc.peek()) { + if &pname == rpname { + rpc.next(); + continue; + } else { + equal_thus_far = false; + } + } + } + buf.push(Component::ParentDir); + } + buf.extend(rpc); + if buf.as_os_str().is_empty() { + Cow::Borrowed(Path::new(".")) + } else { + Cow::Owned(buf) + } +} diff --git a/gix-path/tests/convert/mod.rs b/gix-path/tests/convert/mod.rs index 4f43691e6bf..350c5d450b3 100644 --- a/gix-path/tests/convert/mod.rs +++ b/gix-path/tests/convert/mod.rs @@ -62,3 +62,39 @@ mod join_bstr_unix_pathsep { assert_eq!(join_bstr_unix_pathsep(b(""), "/hi"), b("/hi")); } } + +mod relativize_with_prefix { + fn r(path: &str, prefix: &str) -> String { + gix_path::to_unix_separators_on_windows( + gix_path::os_str_into_bstr(gix_path::relativize_with_prefix(path.as_ref(), prefix.as_ref()).as_os_str()) + .expect("no illformed UTF-8"), + ) + .to_string() + } + + #[test] + fn basics() { + assert_eq!( + r("a", "a"), + ".", + "reaching the prefix is signalled by a '.', the current dir" + ); + assert_eq!(r("a/b/c", "a/b"), "c", "'c' is clearly within the current directory"); + assert_eq!( + r("c/b/c", "a/b"), + "../../c/b/c", + "when there is a complete disjoint prefix, we have to get out of it with ../" + ); + assert_eq!( + r("a/a", "a/b"), + "../a", + "when there is mismatch, we have to get out of the CWD" + ); + assert_eq!( + r("a/a", ""), + "a/a", + "empty prefix means nothing happens (and no work is done)" + ); + assert_eq!(r("", ""), "", "empty stays empty"); + } +} diff --git a/gix-pathspec/src/pattern.rs b/gix-pathspec/src/pattern.rs index 20194f3d119..171e7759e5d 100644 --- a/gix-pathspec/src/pattern.rs +++ b/gix-pathspec/src/pattern.rs @@ -110,6 +110,7 @@ impl Pattern { }; self.path = if path == Path::new(".") { + self.nil = true; BString::from(".") } else { let cleaned = PathBuf::from_iter(path.components().filter(|c| !matches!(c, Component::CurDir))); diff --git a/gix-pathspec/src/search/mod.rs b/gix-pathspec/src/search/mod.rs index 9e98183f8aa..54c4ea58a8a 100644 --- a/gix-pathspec/src/search/mod.rs +++ b/gix-pathspec/src/search/mod.rs @@ -1,6 +1,8 @@ use bstr::{BStr, ByteSlice}; +use std::borrow::Cow; +use std::path::Path; -use crate::{Pattern, Search}; +use crate::{MagicSignature, Pattern, Search}; /// Describes a matching pattern within a search for ignored paths. #[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)] @@ -56,6 +58,44 @@ impl Search { .find(|p| !p.value.pattern.is_excluded()) .map_or("".into(), |m| m.value.pattern.path[..self.common_prefix_len].as_bstr()) } + + /// Returns a guaranteed-to-be-directory that is shared across all pathspecs, in its repository-relative form. + /// Thus to be valid, it must be joined with the worktree root. + /// The prefix is the CWD within a worktree passed when [normalizing](crate::Pattern::normalize) the pathspecs. + /// + /// Note that it may well be that the directory isn't available even though there is a [`common_prefix()`](Self::common_prefix), + /// as they are not quire the same. + /// + /// See also: [`maybe_prefix_directory()`](Self::longest_common_directory). + pub fn prefix_directory(&self) -> Cow<'_, Path> { + gix_path::from_bstr( + self.patterns + .iter() + .find(|p| !p.value.pattern.is_excluded()) + .map_or("".into(), |m| m.value.pattern.prefix_directory()), + ) + } + + /// Return the longest possible common directory that is shared across all non-exclusive pathspecs. + /// It must be tested for existence by joining it with a suitable root before being able to use it. + /// Note that if it is returned, it's guaranteed to be longer than the [prefix-directory](Self::prefix_directory). + /// + /// Returns `None` if the returned directory would be empty, or if all pathspecs are exclusive. + pub fn longest_common_directory(&self) -> Option> { + let first_non_excluded = self.patterns.iter().find(|p| !p.value.pattern.is_excluded())?; + let common_prefix = first_non_excluded.value.pattern.path[..self.common_prefix_len].as_bstr(); + let stripped_prefix = if first_non_excluded + .value + .pattern + .signature + .contains(MagicSignature::MUST_BE_DIR) + { + common_prefix + } else { + common_prefix[..common_prefix.rfind_byte(b'/')?].as_bstr() + }; + Some(gix_path::from_bstr(stripped_prefix)) + } } #[derive(Default, Clone, Debug)] diff --git a/gix-pathspec/tests/normalize/mod.rs b/gix-pathspec/tests/normalize/mod.rs index 2fe54f664e6..df3a245d781 100644 --- a/gix-pathspec/tests/normalize/mod.rs +++ b/gix-pathspec/tests/normalize/mod.rs @@ -1,8 +1,25 @@ use std::path::Path; +#[test] +fn consuming_the_entire_prefix_does_not_lead_to_a_single_dot() -> crate::Result { + let spec = normalized_spec("..", "a", "")?; + assert_eq!( + spec.path(), + ".", + "the top-level of the worktree can take the special value '.' to mean 'everything'" + ); + assert!( + spec.is_nil(), + "single is for the worktree top-level, and since it wouldn't match anything we make it nil so it does" + ); + assert_eq!(spec.prefix_directory(), "", "there is no prefix left"); + Ok(()) +} + #[test] fn removes_relative_path_components() -> crate::Result { for (input_path, expected_path, expected_prefix) in [ + ("..", "a", ""), ("c", "a/b/c", "a/b"), ("../c", "a/c", "a"), ("../b/c", "a/b/c", "a"), // this is a feature - prefix components once consumed by .. are lost. Important as paths can contain globs @@ -33,6 +50,7 @@ fn single_dot_is_special_and_directory_is_implied_without_trailing_slash() -> cr for (input_path, expected) in [(".", "."), ("./", ".")] { let spec = normalized_spec(input_path, "", "/repo")?; assert_eq!(spec.path(), expected); + assert!(spec.is_nil(), "such a spec has to match everything"); assert_eq!(spec.prefix_directory(), ""); } Ok(()) diff --git a/gix-pathspec/tests/search/mod.rs b/gix-pathspec/tests/search/mod.rs index 6dd826aa34c..89f51a320ae 100644 --- a/gix-pathspec/tests/search/mod.rs +++ b/gix-pathspec/tests/search/mod.rs @@ -330,6 +330,40 @@ fn simplified_search_handles_nil() -> crate::Result { Ok(()) } +#[test] +fn longest_common_directory_no_prefix() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&["tests/a/", "tests/b/", ":!*.sh"]), None, Path::new(""))?; + assert_eq!(search.common_prefix(), "tests/"); + assert_eq!(search.prefix_directory(), Path::new("")); + assert_eq!( + search.longest_common_directory().expect("present").to_string_lossy(), + "tests/", + "trailing slashes are not stripped" + ); + Ok(()) +} + +#[test] +fn longest_common_directory_with_prefix() -> crate::Result { + let search = gix_pathspec::Search::from_specs( + pathspecs(&["tests/a/", "tests/b/", ":!*.sh"]), + Some(Path::new("a/b")), + Path::new(""), + )?; + assert_eq!(search.common_prefix(), "a/b/tests/"); + assert_eq!( + search.prefix_directory().to_string_lossy(), + "a/b", + "trailing slashes are not contained" + ); + assert_eq!( + search.longest_common_directory().expect("present").to_string_lossy(), + "a/b/tests/", + "trailing slashes are present, they don't matter" + ); + Ok(()) +} + #[test] fn init_with_exclude() -> crate::Result { let search = gix_pathspec::Search::from_specs(pathspecs(&["tests/", ":!*.sh"]), None, Path::new(""))?; @@ -339,6 +373,16 @@ fn init_with_exclude() -> crate::Result { "re-orded so that excluded are first" ); assert_eq!(search.common_prefix(), "tests"); + assert_eq!( + search.prefix_directory(), + Path::new(""), + "there was no prefix during initialization" + ); + assert_eq!( + search.longest_common_directory(), + Some(Path::new("tests").into()), + "but this works here, and it should be tested" + ); assert!( search.can_match_relative_path("tests".into(), Some(true)), "prefix matches" @@ -388,15 +432,21 @@ fn prefixes_are_always_case_sensitive() -> crate::Result { let path = gix_testtools::scripted_fixture_read_only("match_baseline_files.sh")?.join("paths"); let items = baseline::parse_paths(path)?; - for (spec, prefix, common_prefix, expected) in [ - (":(icase)bar", "FOO", "FOO", &["FOO/BAR", "FOO/bAr", "FOO/bar"] as &[_]), - (":(icase)bar", "F", "F", &[]), - (":(icase)bar", "FO", "FO", &[]), - (":(icase)../bar", "fOo", "", &["BAR", "bAr", "bar"]), - ("../bar", "fOo", "bar", &["bar"]), - (" ", "", " ", &[" "]), // whitespace can match verbatim - (" hi*", "", " hi", &[" hi "]), // whitespace can match with globs as well - (":(icase)../bar", "fO", "", &["BAR", "bAr", "bar"]), // prefixes are virtual, and don't have to exist at all. + for (spec, prefix, common_prefix, expected, expected_common_dir) in [ + ( + ":(icase)bar", + "FOO", + "FOO", + &["FOO/BAR", "FOO/bAr", "FOO/bar"] as &[_], + "FOO", + ), + (":(icase)bar", "F", "F", &[], "F"), + (":(icase)bar", "FO", "FO", &[], "FO"), + (":(icase)../bar", "fOo", "", &["BAR", "bAr", "bar"], ""), + ("../bar", "fOo", "bar", &["bar"], ""), + (" ", "", " ", &[" "], ""), // whitespace can match verbatim + (" hi*", "", " hi", &[" hi "], ""), // whitespace can match with globs as well + (":(icase)../bar", "fO", "", &["BAR", "bAr", "bar"], ""), // prefixes are virtual, and don't have to exist at all. ( ":(icase)../foo/bar", "FOO", @@ -404,8 +454,9 @@ fn prefixes_are_always_case_sensitive() -> crate::Result { &[ "FOO/BAR", "FOO/bAr", "FOO/bar", "fOo/BAR", "fOo/bAr", "fOo/bar", "foo/BAR", "foo/bAr", "foo/bar", ], + "", ), - ("../foo/bar", "FOO", "foo/bar", &["foo/bar"]), + ("../foo/bar", "FOO", "foo/bar", &["foo/bar"], ""), ( ":(icase)../foo/../fOo/bar", "FOO", @@ -413,8 +464,9 @@ fn prefixes_are_always_case_sensitive() -> crate::Result { &[ "FOO/BAR", "FOO/bAr", "FOO/bar", "fOo/BAR", "fOo/bAr", "fOo/bar", "foo/BAR", "foo/bAr", "foo/bar", ], + "", ), - ("../foo/../fOo/BAR", "FOO", "fOo/BAR", &["fOo/BAR"]), + ("../foo/../fOo/BAR", "FOO", "fOo/BAR", &["fOo/BAR"], ""), ] { let mut search = gix_pathspec::Search::from_specs( gix_pathspec::parse(spec.as_bytes(), Default::default()), @@ -422,6 +474,7 @@ fn prefixes_are_always_case_sensitive() -> crate::Result { Path::new(""), )?; assert_eq!(search.common_prefix(), common_prefix, "{spec} {prefix}"); + assert_eq!(search.prefix_directory(), Path::new(expected_common_dir)); let actual: Vec<_> = items .iter() .filter(|relative_path| { @@ -453,13 +506,13 @@ fn prefixes_are_always_case_sensitive() -> crate::Result { #[test] fn common_prefix() -> crate::Result { - for (specs, prefix, expected) in [ - (&["foo/bar", ":(icase)foo/bar"] as &[_], None, ""), - (&["foo/bar", "foo"], None, "foo"), - (&["foo/bar/baz", "foo/bar/"], None, "foo/bar"), // directory trailing slashes are ignored, but that prefix shouldn't care anyway - (&[":(icase)bar", ":(icase)bart"], Some("foo"), "foo"), // only case-sensitive portions count - (&["bar", "bart"], Some("foo"), "foo/bar"), // otherwise everything that matches counts - (&["bar", "bart", "ba"], Some("foo"), "foo/ba"), + for (specs, prefix, expected_common_prefix, expected_common_dir) in [ + (&["foo/bar", ":(icase)foo/bar"] as &[_], None, "", ""), + (&["foo/bar", "foo"], None, "foo", ""), + (&["foo/bar/baz", "foo/bar/"], None, "foo/bar", ""), // directory trailing slashes are ignored, but that prefix shouldn't care anyway + (&[":(icase)bar", ":(icase)bart"], Some("foo"), "foo", "foo"), // only case-sensitive portions count + (&["bar", "bart"], Some("foo"), "foo/bar", "foo"), // otherwise everything that matches counts + (&["bar", "bart", "ba"], Some("foo"), "foo/ba", "foo"), ] { let search = gix_pathspec::Search::from_specs( specs @@ -468,7 +521,12 @@ fn common_prefix() -> crate::Result { prefix.map(Path::new), Path::new(""), )?; - assert_eq!(search.common_prefix(), expected, "{specs:?} {prefix:?}"); + assert_eq!(search.common_prefix(), expected_common_prefix, "{specs:?} {prefix:?}"); + assert_eq!( + search.prefix_directory(), + Path::new(expected_common_dir), + "{specs:?} {prefix:?}" + ); } Ok(()) } diff --git a/gix-status/Cargo.toml b/gix-status/Cargo.toml index 16810f3bd92..f916bbacdf2 100644 --- a/gix-status/Cargo.toml +++ b/gix-status/Cargo.toml @@ -22,6 +22,7 @@ gix-path = { version = "^0.10.5", path = "../gix-path" } gix-features = { version = "^0.38.0", path = "../gix-features" } gix-filter = { version = "^0.9.0", path = "../gix-filter" } gix-worktree = { version = "^0.31.0", path = "../gix-worktree", default-features = false, features = ["attributes"] } +gix-pathspec = { version = "^0.6.0", path = "../gix-pathspec" } thiserror = "1.0.26" filetime = "0.2.15" diff --git a/gix-status/src/index_as_worktree/function.rs b/gix-status/src/index_as_worktree/function.rs index cbf6010e3de..e69d6629b64 100644 --- a/gix-status/src/index_as_worktree/function.rs +++ b/gix-status/src/index_as_worktree/function.rs @@ -2,7 +2,7 @@ use std::{ io, path::Path, slice::Chunks, - sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + sync::atomic::{AtomicU64, AtomicUsize, Ordering}, }; use bstr::BStr; @@ -11,6 +11,7 @@ use gix_features::parallel::{in_parallel_if, Reduce}; use gix_filter::pipeline::convert::ToGitOutcome; use gix_object::FindExt; +use crate::index_as_worktree::Context; use crate::{ index_as_worktree::{ traits, @@ -18,7 +19,7 @@ use crate::{ types::{Error, Options}, Change, Conflict, EntryStatus, Outcome, VisitEntry, }, - Pathspec, SymlinkCheck, + SymlinkCheck, }; /// Calculates the changes that need to be applied to an `index` to match the state of the `worktree` and makes them @@ -26,15 +27,14 @@ use crate::{ /// `submodule` which can take a look at submodules in detail to produce status information (BASE version if its conflicting). /// `options` are used to configure the operation. /// +/// Note `worktree` must be the root path of the worktree, not a path inside of the worktree. +/// /// Note that `index` may require changes to be up-to-date with the working tree and avoid expensive computations by updating /// respective entries with stat information from the worktree, and its timestamp is adjusted to the current time for which it /// will be considered fresh. All changes that would be applied to the index are delegated to the caller, which receives these /// as [`EntryStatus`]. /// The `pathspec` is used to determine which index entries to check for status in the first place. /// -/// `should_interrupt` can be used to stop all processing. -/// `filter` is used to convert worktree files back to their internal git representation. For this to be correct, -/// [`Options::attributes`] must be configured as well. /// `objects` is used to access the version of an object in the object database for direct comparison. /// /// **It's important to note that the `index` should have its [timestamp updated](gix_index::State::set_timestamp()) with a timestamp @@ -47,7 +47,7 @@ use crate::{ /// stats like `git status` would if it had to determine the hash. /// If that happened, the index should be written back after updating the entries with these updated stats, see [Outcome::skipped]. /// -/// Thus some care has to be taken to do the right thing when letting the index match the worktree by evaluating the changes observed +/// Thus, some care has to be taken to do the right thing when letting the index match the worktree by evaluating the changes observed /// by the `collector`. #[allow(clippy::too_many_arguments)] pub fn index_as_worktree<'index, T, U, Find, E>( @@ -58,10 +58,13 @@ pub fn index_as_worktree<'index, T, U, Find, E>( submodule: impl SubmoduleStatus + Send + Clone, objects: Find, progress: &mut dyn gix_features::progress::Progress, - pathspec: impl Pathspec + Send + Clone, - filter: gix_filter::Pipeline, - should_interrupt: &AtomicBool, - mut options: Options, + Context { + pathspec, + stack, + filter, + should_interrupt, + }: Context<'_>, + options: Options, ) -> Result where T: Send, @@ -84,20 +87,13 @@ where .prefixed_entries_range(pathspec.common_prefix()) .unwrap_or(0..index.entries().len()); - let stack = gix_worktree::Stack::from_state_and_ignore_case( - worktree, - options.fs.ignore_case, - gix_worktree::stack::State::AttributesStack(std::mem::take(&mut options.attributes)), - index, - index.path_backing(), - ); let (entries, path_backing) = (index.entries(), index.path_backing()); let mut num_entries = entries.len(); let entry_index_offset = range.start; let entries = &entries[range]; let _span = gix_features::trace::detail!("gix_status::index_as_worktree", - num_entries = entries.len(), + num_entries = entries.len(), chunk_size = chunk_size, thread_limit = ?thread_limit); @@ -253,7 +249,7 @@ impl<'index> State<'_, 'index> { entries: &'index [gix_index::Entry], entry: &'index gix_index::Entry, entry_index: usize, - pathspec: &mut impl Pathspec, + pathspec: &mut gix_pathspec::Search, diff: &mut impl CompareBlobs, submodule: &mut impl SubmoduleStatus, objects: &Find, @@ -273,7 +269,20 @@ impl<'index> State<'_, 'index> { return None; } let path = entry.path_in(self.path_backing); - if !pathspec.is_included(path, Some(false)) { + let is_excluded = pathspec + .pattern_matching_relative_path( + path, + Some(entry.mode.is_submodule()), + &mut |relative_path, case, is_dir, out| { + self.attr_stack + .set_case(case) + .at_entry(relative_path, Some(is_dir), objects) + .map_or(false, |platform| platform.matching_attributes(out)) + }, + ) + .map_or(true, |m| m.is_excluded()); + + if is_excluded { self.skipped_by_pathspec.fetch_add(1, Ordering::Relaxed); return None; } diff --git a/gix-status/src/index_as_worktree/mod.rs b/gix-status/src/index_as_worktree/mod.rs index 412068875d0..96694078bb8 100644 --- a/gix-status/src/index_as_worktree/mod.rs +++ b/gix-status/src/index_as_worktree/mod.rs @@ -1,7 +1,7 @@ //! Changes between an index and a worktree. /// mod types; -pub use types::{Change, Conflict, EntryStatus, Error, Options, Outcome, VisitEntry}; +pub use types::{Change, Conflict, Context, EntryStatus, Error, Options, Outcome, VisitEntry}; mod recorder; pub use recorder::{Record, Recorder}; diff --git a/gix-status/src/index_as_worktree/types.rs b/gix-status/src/index_as_worktree/types.rs index 0f253f553de..6d93f784f12 100644 --- a/gix-status/src/index_as_worktree/types.rs +++ b/gix-status/src/index_as_worktree/types.rs @@ -1,4 +1,5 @@ use bstr::{BStr, BString}; +use std::sync::atomic::AtomicBool; /// The error returned by [index_as_worktree()`](crate::index_as_worktree()). #[derive(Debug, thiserror::Error)] @@ -30,11 +31,25 @@ pub struct Options { pub thread_limit: Option, /// Options that control how stat comparisons are made when checking if a file is fresh. pub stat: gix_index::entry::stat::Options, - /// Pre-configured state to allow processing attributes. +} + +/// The context for [index_as_worktree()`](crate::index_as_worktree()). +#[derive(Clone)] +pub struct Context<'a> { + /// The pathspec to limit the amount of paths that are checked. Can be empty to allow all paths. + pub pathspec: gix_pathspec::Search, + /// A stack pre-configured to allow accessing attributes for each entry, as required for `filter` + /// and possibly pathspecs. + pub stack: gix_worktree::Stack, + /// A filter to be able to perform conversions from and to the worktree format. + /// + /// It is needed to potentially refresh the index with data read from the worktree, which needs to be converted back + /// to the form stored in Git. /// - /// These are needed to potentially refresh the index with data read from the worktree, which needs to be converted back - /// to the form stored in git. - pub attributes: gix_worktree::stack::state::Attributes, + /// Note that for this to be correct, the attribute `stack` must be configured correctly as well. + pub filter: gix_filter::Pipeline, + /// A flag to query to learn if cancellation is requested. + pub should_interrupt: &'a AtomicBool, } /// Provide additional information collected during the runtime of [`index_as_worktree()`](crate::index_as_worktree()). diff --git a/gix-status/src/lib.rs b/gix-status/src/lib.rs index 850c47ab8a7..0749c5bd6cb 100644 --- a/gix-status/src/lib.rs +++ b/gix-status/src/lib.rs @@ -8,27 +8,9 @@ //! While also being able to check check if the working tree is dirty, quickly. #![deny(missing_docs, rust_2018_idioms, unsafe_code)] -use bstr::BStr; - pub mod index_as_worktree; pub use index_as_worktree::function::index_as_worktree; -/// A trait to facilitate working working with pathspecs. -pub trait Pathspec { - /// Return the portion of the prefix among all of the pathspecs involved in this search, or an empty string if - /// there is none. It doesn't have to end at a directory boundary though, nor does it denote a directory. - /// - /// Note that the common_prefix is always matched case-sensitively, and it is useful to skip large portions of input. - /// Further, excluded pathspecs don't participate which makes this common prefix inclusive. To work correctly though, - /// one will have to additionally match paths that have the common prefix with that pathspec itself to assure it is - /// not excluded. - fn common_prefix(&self) -> &BStr; - - /// Return `true` if `relative_path` is included in this pathspec. - /// `is_dir` is `true` if `relative_path` is a directory. - fn is_included(&mut self, relative_path: &BStr, is_dir: Option) -> bool; -} - /// A stack that validates we are not going through a symlink in a way that is read-only. /// /// It can efficiently validate paths when these are queried in sort-order, which leads to each component diff --git a/gix-status/tests/Cargo.toml b/gix-status/tests/Cargo.toml index 9ca28ff6ea4..2e138b9a184 100644 --- a/gix-status/tests/Cargo.toml +++ b/gix-status/tests/Cargo.toml @@ -25,6 +25,7 @@ gix-hash = { path = "../../gix-hash" } gix-object = { path = "../../gix-object" } gix-features = { path = "../../gix-features" } gix-pathspec = { path = "../../gix-pathspec" } +gix-worktree = { path = "../../gix-worktree" } filetime = "0.2.15" bstr = { version = "1.3.0", default-features = false } diff --git a/gix-status/tests/status/index_as_worktree.rs b/gix-status/tests/status/index_as_worktree.rs index 7e3564be3af..d6753e26943 100644 --- a/gix-status/tests/status/index_as_worktree.rs +++ b/gix-status/tests/status/index_as_worktree.rs @@ -7,6 +7,7 @@ use bstr::BStr; use filetime::{set_file_mtime, FileTime}; use gix_index as index; use gix_index::Entry; +use gix_status::index_as_worktree::Context; use gix_status::{ index_as_worktree, index_as_worktree::{ @@ -93,6 +94,13 @@ fn fixture_filtered_detailed( let mut recorder = Recorder::default(); let search = gix_pathspec::Search::from_specs(to_pathspecs(pathspecs), None, std::path::Path::new("")) .expect("valid specs can be normalized"); + let stack = gix_worktree::Stack::from_state_and_ignore_case( + worktree.clone(), + false, + gix_worktree::stack::State::AttributesStack(Default::default()), + &index, + index.path_backing(), + ); let outcome = index_as_worktree( &index, &worktree, @@ -101,9 +109,12 @@ fn fixture_filtered_detailed( SubmoduleStatusMock { dirty: submodule_dirty }, gix_object::find::Never, &mut gix_features::progress::Discard, - Pathspec(search), - Default::default(), - &AtomicBool::default(), + Context { + pathspec: search, + stack, + filter: Default::default(), + should_interrupt: &AtomicBool::default(), + }, Options { fs: gix_fs::Capabilities::probe(&git_dir), stat: TEST_OPTIONS, @@ -606,6 +617,19 @@ fn racy_git() { let count = Arc::new(AtomicUsize::new(0)); let counter = CountCalls(count.clone(), FastEq); + let stack = gix_worktree::Stack::from_state_and_ignore_case( + worktree, + false, + gix_worktree::stack::State::AttributesStack(Default::default()), + &index, + index.path_backing(), + ); + let ctx = Context { + pathspec: default_pathspec(), + stack, + filter: Default::default(), + should_interrupt: &AtomicBool::default(), + }; let out = index_as_worktree( &index, worktree, @@ -614,9 +638,7 @@ fn racy_git() { SubmoduleStatusMock { dirty: false }, gix_object::find::Never, &mut gix_features::progress::Discard, - Pathspec::default(), - Default::default(), - &AtomicBool::default(), + ctx.clone(), Options { fs, stat: TEST_OPTIONS, @@ -653,9 +675,7 @@ fn racy_git() { SubmoduleStatusMock { dirty: false }, gix_object::find::Never, &mut gix_features::progress::Discard, - Pathspec::default(), - Default::default(), - &AtomicBool::default(), + ctx, Options { fs, stat: TEST_OPTIONS, @@ -696,27 +716,6 @@ fn racy_git() { ); } -#[derive(Clone)] -struct Pathspec(gix_pathspec::Search); - -impl Default for Pathspec { - fn default() -> Self { - let search = gix_pathspec::Search::from_specs(to_pathspecs(&[]), None, std::path::Path::new("")) - .expect("empty is always valid"); - Self(search) - } -} - -impl gix_status::Pathspec for Pathspec { - fn common_prefix(&self) -> &BStr { - self.0.common_prefix() - } - - fn is_included(&mut self, relative_path: &BStr, is_dir: Option) -> bool { - self.0 - .pattern_matching_relative_path(relative_path, is_dir, &mut |_, _, _, _| { - unreachable!("we don't use attributes in our pathspecs") - }) - .map_or(false, |m| !m.is_excluded()) - } +fn default_pathspec() -> gix_pathspec::Search { + gix_pathspec::Search::from_specs(to_pathspecs(&[]), None, std::path::Path::new("")).expect("empty is always valid") } diff --git a/gix/src/dirwalk.rs b/gix/src/dirwalk.rs index 693ddc19fce..59357662c63 100644 --- a/gix/src/dirwalk.rs +++ b/gix/src/dirwalk.rs @@ -1,4 +1,4 @@ -use gix_dir::walk::{EmissionMode, ForDeletionMode}; +use gix_dir::walk::{CollapsedEntriesEmissionMode, EmissionMode, ForDeletionMode}; /// Options for use in the [`Repository::dirwalk()`](crate::Repository::dirwalk()) function. /// @@ -16,6 +16,8 @@ pub struct Options { emit_untracked: EmissionMode, emit_empty_directories: bool, classify_untracked_bare_repositories: bool, + emit_collapsed: Option, + pub(crate) empty_patterns_match_prefix: bool, } /// Construction @@ -32,6 +34,8 @@ impl Options { emit_untracked: Default::default(), emit_empty_directories: false, classify_untracked_bare_repositories: false, + emit_collapsed: None, + empty_patterns_match_prefix: false, } } } @@ -49,11 +53,21 @@ impl From for gix_dir::walk::Options { emit_untracked: v.emit_untracked, emit_empty_directories: v.emit_empty_directories, classify_untracked_bare_repositories: v.classify_untracked_bare_repositories, + emit_collapsed: v.emit_collapsed, } } } impl Options { + /// If `true`, default `false`, pathspecs and the directory walk itself will be setup to use the [prefix](crate::Repository::prefix) + /// if patterns are empty. + /// + /// This means that the directory walk will be limited to only what's inside the [repository prefix](crate::Repository::prefix). + /// By default, the directory walk will see everything. + pub fn empty_patterns_match_prefix(mut self, toggle: bool) -> Self { + self.empty_patterns_match_prefix = toggle; + self + } /// If `toggle` is `true`, we will stop figuring out if any directory that is a candidate for recursion is also a nested repository, /// which saves time but leads to recurse into it. If `false`, nested repositories will not be traversed. pub fn recurse_repositories(mut self, toggle: bool) -> Self { @@ -106,4 +120,11 @@ impl Options { self.classify_untracked_bare_repositories = toggle; self } + + /// Control whether entries that are in an about-to-be collapsed directory will be emitted. The default is `None`, + /// so entries in a collapsed directory are not observable. + pub fn emit_collapsed(mut self, value: Option) -> Self { + self.emit_collapsed = value; + self + } } diff --git a/gix/src/pathspec.rs b/gix/src/pathspec.rs index a56ad1c321e..08ac5e5230a 100644 --- a/gix/src/pathspec.rs +++ b/gix/src/pathspec.rs @@ -32,6 +32,8 @@ impl<'repo> Pathspec<'repo> { /// be used to control where attributes are coming from. /// If `inherit_ignore_case` is `true`, the pathspecs may have their ignore-case default overridden to be case-insensitive by default. /// This only works towards turning ignore-case for pathspecs on, but won't ever turn that setting off if. + /// If `empty_patterns_match_prefix` is `true`, then even empty patterns will match only what's inside of the prefix. Otherwise + /// they will match everything. /// /// ### Deviation /// @@ -39,6 +41,7 @@ impl<'repo> Pathspec<'repo> { /// queries as well. pub fn new( repo: &'repo Repository, + empty_patterns_match_prefix: bool, patterns: impl IntoIterator>, inherit_ignore_case: bool, make_attributes: impl FnOnce() -> Result>, @@ -49,9 +52,14 @@ impl<'repo> Pathspec<'repo> { .map(move |p| parse(p.as_ref(), defaults)) .collect::, _>>()?; let needs_cache = patterns.iter().any(|p| !p.attributes.is_empty()); + let prefix = if patterns.is_empty() && !empty_patterns_match_prefix { + None + } else { + repo.prefix()? + }; let search = Search::from_specs( patterns, - repo.prefix()?, + prefix, &gix_path::realpath_opts( repo.work_dir().unwrap_or_else(|| repo.git_dir()), repo.options.current_dir_or_empty(), @@ -191,14 +199,3 @@ impl PathspecDetached { .map_or(false, |m| !m.is_excluded()) } } - -#[cfg(feature = "status")] -impl gix_status::Pathspec for PathspecDetached { - fn common_prefix(&self) -> &BStr { - self.search.common_prefix() - } - - fn is_included(&mut self, relative_path: &BStr, is_dir: Option) -> bool { - self.is_included(relative_path, is_dir) - } -} diff --git a/gix/src/repository/dirwalk.rs b/gix/src/repository/dirwalk.rs index 6d47247f6b3..2495b544925 100644 --- a/gix/src/repository/dirwalk.rs +++ b/gix/src/repository/dirwalk.rs @@ -1,6 +1,6 @@ use crate::bstr::BStr; -use crate::{config, dirwalk, Repository}; -use std::path::Path; +use crate::{config, dirwalk, AttributeStack, Pathspec, Repository}; +use std::path::PathBuf; /// The error returned by [dirwalk()](Repository::dirwalk()). #[derive(Debug, thiserror::Error)] @@ -20,6 +20,19 @@ pub enum Error { FilesystemOptions(#[from] config::boolean::Error), } +/// The outcome of the [dirwalk()](Repository::dirwalk). +pub struct Outcome<'repo> { + /// The excludes stack used for the dirwalk, for access of `.gitignore` information. + pub excludes: AttributeStack<'repo>, + /// The pathspecs used to guide the operation, + pub pathspec: Pathspec<'repo>, + /// The root actually being used for the traversal, and useful to transform the paths returned for the user. + /// It's always within the [`work-dir`](Repository::work_dir). + pub traversal_root: PathBuf, + /// The actual result of the dirwalk. + pub dirwalk: gix_dir::walk::Outcome, +} + impl Repository { /// Return default options suitable for performing a directory walk on this repository. /// @@ -42,40 +55,42 @@ impl Repository { patterns: impl IntoIterator>, options: dirwalk::Options, delegate: &mut dyn gix_dir::walk::Delegate, - ) -> Result { + ) -> Result, Error> { + let _span = gix_trace::coarse!("gix::dirwalk"); let workdir = self.work_dir().ok_or(Error::MissinWorkDir)?; - let mut excludes = self - .excludes( - index, - None, - crate::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, - )? - .detach(); - let (mut pathspec, mut maybe_attributes) = self - .pathspec( - patterns, - true, /* inherit ignore case */ - index, - crate::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, - )? - .into_parts(); + let mut excludes = self.excludes( + index, + None, + crate::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, + )?; + let mut pathspec = self.pathspec( + options.empty_patterns_match_prefix, /* empty patterns match prefix */ + patterns, + true, /* inherit ignore case */ + index, + crate::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, + )?; + gix_trace::debug!( + longest_prefix = ?pathspec.search.longest_common_directory(), + prefix_dir = ?pathspec.search.prefix_directory(), + patterns = ?pathspec.search.patterns().map(gix_pathspec::Pattern::path).collect::>() + ); - let prefix = self.prefix()?.unwrap_or(Path::new("")); let git_dir_realpath = crate::path::realpath_opts(self.git_dir(), self.current_dir(), crate::path::realpath::MAX_SYMLINKS)?; let fs_caps = self.filesystem_options()?; let accelerate_lookup = fs_caps.ignore_case.then(|| index.prepare_icase_backing()); - gix_dir::walk( - &workdir.join(prefix), + let (outcome, traversal_root) = gix_dir::walk( workdir, gix_dir::walk::Context { git_dir_realpath: git_dir_realpath.as_ref(), current_dir: self.current_dir(), index, ignore_case_index_lookup: accelerate_lookup.as_ref(), - pathspec: &mut pathspec, + pathspec: &mut pathspec.search, pathspec_attributes: &mut |relative_path, case, is_dir, out| { - let stack = maybe_attributes + let stack = pathspec + .stack .as_mut() .expect("can only be called if attributes are used in patterns"); stack @@ -83,12 +98,19 @@ impl Repository { .at_entry(relative_path, Some(is_dir), &self.objects) .map_or(false, |platform| platform.matching_attributes(out)) }, - excludes: Some(&mut excludes), + excludes: Some(&mut excludes.inner), objects: &self.objects, + explicit_traversal_root: (!options.empty_patterns_match_prefix).then_some(workdir), }, options.into(), delegate, - ) - .map_err(Into::into) + )?; + + Ok(Outcome { + dirwalk: outcome, + traversal_root, + excludes, + pathspec, + }) } } diff --git a/gix/src/repository/filter.rs b/gix/src/repository/filter.rs index 68644ca984e..0ad7c0ef609 100644 --- a/gix/src/repository/filter.rs +++ b/gix/src/repository/filter.rs @@ -52,7 +52,7 @@ impl Repository { let cache = self.attributes_only(&index, gix_worktree::stack::state::attributes::Source::IdMapping)?; (cache, IndexPersistedOrInMemory::InMemory(index)) } else { - let index = self.index()?; + let index = self.index_or_empty()?; let cache = self.attributes_only( &index, gix_worktree::stack::state::attributes::Source::WorktreeThenIdMapping, diff --git a/gix/src/repository/pathspec.rs b/gix/src/repository/pathspec.rs index 8e7e9bbe9a4..1b6d9067727 100644 --- a/gix/src/repository/pathspec.rs +++ b/gix/src/repository/pathspec.rs @@ -8,18 +8,21 @@ impl Repository { /// (but also note that `git` does not do that). /// `index` may be needed to load attributes which is required only if `patterns` refer to attributes via `:(attr:…)` syntax. /// In the same vein, `attributes_source` affects where `.gitattributes` files are read from if pathspecs need to match against attributes. + /// If `empty_patterns_match_prefix` is `true`, then even empty patterns will match only what's inside of the prefix. Otherwise + /// they will match everything. /// /// It will be initialized exactly how it would, and attribute matching will be conducted by reading the worktree first if available. /// If that is not desirable, consider calling [`Pathspec::new()`] directly. #[doc(alias = "Pathspec", alias = "git2")] pub fn pathspec( &self, + empty_patterns_match_prefix: bool, patterns: impl IntoIterator>, inherit_ignore_case: bool, index: &gix_index::State, attributes_source: gix_worktree::stack::state::attributes::Source, ) -> Result, crate::pathspec::init::Error> { - Pathspec::new(self, patterns, inherit_ignore_case, || { + Pathspec::new(self, empty_patterns_match_prefix, patterns, inherit_ignore_case, || { self.attributes_only(index, attributes_source) .map(AttributeStack::detach) .map_err(Into::into) diff --git a/gix/src/worktree/mod.rs b/gix/src/worktree/mod.rs index b0a1cc6f444..f0a0c44a780 100644 --- a/gix/src/worktree/mod.rs +++ b/gix/src/worktree/mod.rs @@ -219,6 +219,8 @@ pub mod pathspec { /// Configure pathspecs `patterns` to be matched against, with pathspec attributes read from the worktree and then from the index /// if needed. /// + /// Note that the `empty_patterns_match_prefix` flag of the [parent method](crate::Repository::pathspec()) defaults to `true`. + /// /// ### Deviation /// /// Pathspec attributes match case-insensitively by default if the underlying filesystem is configured that way. @@ -244,6 +246,7 @@ pub mod pathspec { .map_err(|err| Error::Init(crate::pathspec::init::Error::Defaults(err.into())))? .unwrap_or(gitoxide::Pathspec::INHERIT_IGNORE_CASE_DEFAULT); Ok(self.parent.pathspec( + true, /* empty patterns match prefix */ patterns, inherit_ignore_case, &index, diff --git a/gix/tests/fixtures/generated-archives/make_basic_repo.tar.xz b/gix/tests/fixtures/generated-archives/make_basic_repo.tar.xz index af6aa912b60..dc37073d9cd 100644 Binary files a/gix/tests/fixtures/generated-archives/make_basic_repo.tar.xz and b/gix/tests/fixtures/generated-archives/make_basic_repo.tar.xz differ diff --git a/gix/tests/fixtures/make_basic_repo.sh b/gix/tests/fixtures/make_basic_repo.sh index c3cbc8697fc..ac50aea64cb 100755 --- a/gix/tests/fixtures/make_basic_repo.sh +++ b/gix/tests/fixtures/make_basic_repo.sh @@ -25,3 +25,10 @@ git init non-bare-repo-without-index git add this && git commit -m "init" rm .git/index ) + +git init all-untracked +(cd all-untracked + >a + mkdir d + >d/a +) \ No newline at end of file diff --git a/gix/tests/repository/filter.rs b/gix/tests/repository/filter.rs index 9a708a17741..ea555b9b5a3 100644 --- a/gix/tests/repository/filter.rs +++ b/gix/tests/repository/filter.rs @@ -1,9 +1,16 @@ use std::path::Path; +#[test] +fn pipeline_in_nonbare_repo_without_index() -> crate::Result { + let repo = named_subrepo_opts("make_basic_repo.sh", "all-untracked", Default::default())?; + let _ = repo.filter_pipeline(None).expect("does not fail due to missing index"); + Ok(()) +} + use gix::bstr::ByteSlice; use gix_filter::driver::apply::Delay; -use crate::util::named_repo; +use crate::util::{named_repo, named_subrepo_opts}; #[test] fn pipeline_in_repo_without_special_options() -> crate::Result { diff --git a/gix/tests/repository/mod.rs b/gix/tests/repository/mod.rs index 9d11348007a..80a711e17f0 100644 --- a/gix/tests/repository/mod.rs +++ b/gix/tests/repository/mod.rs @@ -34,6 +34,7 @@ mod dirwalk { .map(|e| (e.0.rela_path.to_string(), e.0.disk_kind.expect("kind is known"))) .collect::>(), [ + ("all-untracked".to_string(), Repository), ("bare-repo-with-index.git".to_string(), Directory), ("bare.git".into(), Directory), ("non-bare-repo-without-index".into(), Repository), diff --git a/gix/tests/repository/pathspec.rs b/gix/tests/repository/pathspec.rs index 9794603840d..d4b61235ce6 100644 --- a/gix/tests/repository/pathspec.rs +++ b/gix/tests/repository/pathspec.rs @@ -9,7 +9,9 @@ fn defaults_are_taken_from_repo_config() -> crate::Result { repo.config_snapshot_mut() .set_value(&gitoxide::Pathspec::ICASE, "true")?; let inherit_ignore_case = true; + let empty_pathspecs_match_prefix = true; let mut pathspec = repo.pathspec( + empty_pathspecs_match_prefix, [ "hi", ":!hip",