From c04954a89dfdd8c230050b6175e2a132c73bdbfa Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 15 Feb 2024 08:35:02 +0100 Subject: [PATCH 01/17] fix: assure `Action::Cancel` doesn't run into unreachable code. --- gix-dir/src/walk/function.rs | 1 - gix-dir/src/walk/readdir.rs | 2 +- gix-dir/tests/walk/mod.rs | 87 ++++++++++++++++++++++++++++----- gix-dir/tests/walk_utils/mod.rs | 25 +++++++--- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/gix-dir/src/walk/function.rs b/gix-dir/src/walk/function.rs index d8015a98fd0..43c8cf76542 100644 --- a/gix-dir/src/walk/function.rs +++ b/gix-dir/src/walk/function.rs @@ -83,7 +83,6 @@ pub fn walk( &mut out, &mut state, )?; - assert_eq!(state.on_hold.len(), 0, "BUG: must be fully consumed"); gix_trace::debug!(statistics = ?out); Ok(out) } diff --git a/gix-dir/src/walk/readdir.rs b/gix-dir/src/walk/readdir.rs index be656a66081..b45419f0820 100644 --- a/gix-dir/src/walk/readdir.rs +++ b/gix-dir/src/walk/readdir.rs @@ -68,7 +68,7 @@ pub(super) fn recursive( recursive(false, 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); diff --git a/gix-dir/tests/walk/mod.rs b/gix-dir/tests/walk/mod.rs index 49014f7003c..58227043f59 100644 --- a/gix-dir/tests/walk/mod.rs +++ b/gix-dir/tests/walk/mod.rs @@ -1,10 +1,12 @@ -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, + fixture_in, options, options_emit_all, try_collect, try_collect_filtered_opts, try_collect_filtered_opts_collect, + EntryExt, Options, }; +use gix_dir::entry; use gix_dir::entry::Kind::*; use gix_dir::entry::PathspecMatch::*; use gix_dir::entry::Status::*; @@ -1823,7 +1825,7 @@ 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 (out, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -1857,7 +1859,7 @@ fn root_enters_directory_with_dot_git_in_reconfigured_worktree_tracked() -> crat "everything is tracked, so it won't try to detect git repositories anyway" ); - let (out, entries) = try_collect_filtered_opts( + let (out, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -1891,7 +1893,7 @@ 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 (_out, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -2091,6 +2093,69 @@ fn root_can_be_pruned_early_with_pathspec() -> crate::Result { 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; + dbg!(self.emits_left_until_cancel); + 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, + |keep, ctx| { + walk( + &root, + &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"); @@ -2751,7 +2816,7 @@ 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, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -2794,7 +2859,7 @@ 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, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -2839,7 +2904,7 @@ fn type_mismatch() { #[test] fn type_mismatch_ignore_case() { let root = fixture("type-mismatch-icase"); - let (out, entries) = try_collect_filtered_opts( + let (out, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -2879,7 +2944,7 @@ 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, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -2923,7 +2988,7 @@ 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, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( @@ -2964,7 +3029,7 @@ 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, entries) = try_collect_filtered_opts_collect( &root, |keep, ctx| { walk( diff --git a/gix-dir/tests/walk_utils/mod.rs b/gix-dir/tests/walk_utils/mod.rs index 3f7fdde3395..0d8c3594f8a 100644 --- a/gix-dir/tests/walk_utils/mod.rs +++ b/gix-dir/tests/walk_utils/mod.rs @@ -169,15 +169,27 @@ pub fn try_collect_filtered( cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, patterns: impl IntoIterator>, ) -> Result<(walk::Outcome, Entries), walk::Error> { - try_collect_filtered_opts(worktree_root, cb, patterns, Default::default()) + try_collect_filtered_opts_collect(worktree_root, cb, patterns, Default::default()) +} + +pub fn try_collect_filtered_opts_collect( + worktree_root: &Path, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + patterns: impl IntoIterator>, + options: Options<'_>, +) -> Result<(walk::Outcome, Entries), walk::Error> { + let mut dlg = gix_dir::walk::delegate::Collect::default(); + let outcome = try_collect_filtered_opts(worktree_root, cb, patterns, &mut dlg, options)?; + Ok((outcome, dlg.into_entries_by_path())) } pub fn try_collect_filtered_opts( worktree_root: &Path, cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, patterns: impl IntoIterator>, + delegate: &mut dyn gix_dir::walk::Delegate, Options { fresh_index, git_dir }: Options<'_>, -) -> Result<(walk::Outcome, Entries), walk::Error> { +) -> Result { 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), @@ -229,10 +241,9 @@ pub fn try_collect_filtered_opts( let cwd = gix_fs::current_dir(false).expect("valid cwd"); 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, @@ -243,9 +254,7 @@ pub fn try_collect_filtered_opts( excludes: Some(&mut stack), objects: &gix_object::find::Never, }, - )?; - - Ok((outcome, dlg.into_entries_by_path())) + ) } pub struct Options<'a> { From 4fd31739b0f34947d6c4d68ae430a792af4c4e37 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 15 Feb 2024 08:27:33 +0100 Subject: [PATCH 02/17] `gix clean` is interruptable --- gitoxide-core/src/repository/clean.rs | 33 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/gitoxide-core/src/repository/clean.rs b/gitoxide-core/src/repository/clean.rs index 29c2517f621..071be245b89 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, @@ -55,7 +57,7 @@ pub(crate) mod function { let index = repo.index()?; 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()? @@ -74,14 +76,14 @@ pub(crate) mod function { .emit_ignored(Some(collapse_directories)) .emit_empty_directories(true); repo.dirwalk(&index, patterns, options, &mut collect)?; - let prefix = repo.prefix()?.expect("worktree and valid current dir"); + let prefix = repo.prefix()?.unwrap_or(Path::new("")); let prefix_len = if prefix.as_os_str().is_empty() { 0 } else { prefix.to_str().map_or(0, |s| s.len() + 1 /* slash */) }; - 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 +145,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; } @@ -215,6 +217,9 @@ pub(crate) mod function { }, )?; + if gix::interrupt::is_triggered() { + execute = false; + } if execute { let path = workdir.join(gix::path::from_bstr(entry.rela_path)); if disk_kind.is_dir() { @@ -286,7 +291,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 + } + } } From e52ad782f3b16a6c39f6dba1d25808fe55d26082 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 14 Feb 2024 20:40:59 +0100 Subject: [PATCH 03/17] feat: `gix status` now shows untracked files as well. --- gitoxide-core/Cargo.toml | 2 +- gitoxide-core/src/repository/status.rs | 82 ++++++++++++++++++++------ 2 files changed, 65 insertions(+), 19 deletions(-) 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/repository/status.rs b/gitoxide-core/src/repository/status.rs index 6d4207a03f1..021f5960fea 100644 --- a/gitoxide-core/src/repository/status.rs +++ b/gitoxide-core/src/repository/status.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context}; +use gix::bstr::ByteSlice; use gix::{ bstr::{BStr, BString}, index::Entry, @@ -46,7 +47,7 @@ pub fn show( let mut index = repo.index_or_empty()?; let index = gix::threading::make_mut(&mut index); let pathspec = repo.pathspec( - pathspecs, + pathspecs.iter().map(|p| p.as_bstr()), true, index, gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, @@ -74,23 +75,67 @@ pub fn show( out, changes: Vec::new(), }; - 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 filter_pipeline = repo + .filter_pipeline(Some(gix::hash::ObjectId::empty_tree(repo.object_hash())))? + .0 + .into_parts() + .0; + + 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 || { + let repo = repo.to_thread_local(); + repo.dirwalk( + index, + pathspecs, + repo.dirwalk_options()? + .emit_untracked(gix::dir::walk::EmissionMode::CollapseDirectory), + collect, + ) + } + })?; + + 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()?, + filter_pipeline, + &gix::interrupt::IS_INTERRUPTED, + 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 = entry.rela_path + )?; + } if outcome.entries_to_update != 0 && allow_write { { @@ -115,6 +160,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")?; From dc200bf6f2cb10b6f0e45dd83bf9f82173cbb04f Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 15 Feb 2024 11:12:39 +0100 Subject: [PATCH 04/17] fix: proper submodule handling Previously it was possible for `.git` files in directories to not trigger repository detection. --- gix-dir/src/walk/classify.rs | 11 ++-- .../fixtures/generated-archives/.gitignore | 1 - gix-dir/tests/fixtures/many.sh | 13 ++++ gix-dir/tests/walk/mod.rs | 65 ++++++++++++++++++- 4 files changed, 84 insertions(+), 6 deletions(-) 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/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 58227043f59..04233b339bb 100644 --- a/gix-dir/tests/walk/mod.rs +++ b/gix-dir/tests/walk/mod.rs @@ -2093,6 +2093,70 @@ fn root_can_be_pruned_early_with_pathspec() -> crate::Result { Ok(()) } +#[test] +fn submodules() -> crate::Result { + let root = fixture("multiple-submodules"); + let (out, entries) = collect(&root, |keep, ctx| walk(&root, &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, entries) = try_collect_filtered_opts_collect( + &root, + |keep, ctx| walk(&root, &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, entries) = try_collect_filtered_opts_collect( + &root, + |keep, ctx| { + walk( + &root, + &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 { @@ -2105,7 +2169,6 @@ fn cancel_with_collection_does_not_fail() -> crate::Result { walk::Action::Cancel } else { self.emits_left_until_cancel -= 1; - dbg!(self.emits_left_until_cancel); walk::Action::Continue } } From 4567dbb2abf3d05bebe2206afafc40002a376d26 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 15 Feb 2024 15:18:06 +0100 Subject: [PATCH 05/17] feat!: allow to emit all collapsed entries. This is useful for rename tracking as it allows to see all files that may take part in a rename (i.e. when a directory is renamed). --- gix-dir/src/walk/mod.rs | 24 +++++++++++++++-- gix-dir/src/walk/readdir.rs | 21 ++++++++++----- gix-dir/tests/walk/mod.rs | 47 +++++++++++++++++++++++++++++++++ gix-dir/tests/walk_utils/mod.rs | 1 + 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/gix-dir/src/walk/mod.rs b/gix-dir/src/walk/mod.rs index 5929d42ca47..623653edc61 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. diff --git a/gix-dir/src/walk/readdir.rs b/gix-dir/src/walk/readdir.rs index b45419f0820..f11d4244476 100644 --- a/gix-dir/src/walk/readdir.rs +++ b/gix-dir/src/walk/readdir.rs @@ -5,7 +5,7 @@ use std::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 @@ -286,12 +286,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/walk/mod.rs b/gix-dir/tests/walk/mod.rs index 04233b339bb..3cb521ee517 100644 --- a/gix-dir/tests/walk/mod.rs +++ b/gix-dir/tests/walk/mod.rs @@ -10,6 +10,7 @@ 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::*; @@ -1060,6 +1061,7 @@ fn untracked_and_ignored() -> crate::Result { walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1147,6 +1149,7 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: None, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1242,6 +1245,7 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crat emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1363,6 +1367,7 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1408,6 +1413,7 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1489,6 +1495,7 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, for_deletion: Some(Default::default()), + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1590,6 +1597,7 @@ fn precious_are_not_expendable() { walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -1629,6 +1637,7 @@ fn precious_are_not_expendable() { walk::Options { emit_ignored: Some(CollapseDirectory), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options() }, keep, @@ -2763,6 +2772,7 @@ fn untracked_and_ignored_collapse_mix() { walk::Options { emit_ignored: Some(Matching), emit_untracked: CollapseDirectory, + emit_collapsed: Some(OnStatusMismatch), ..options_emit_all() }, keep, @@ -2788,6 +2798,43 @@ fn untracked_and_ignored_collapse_mix() { ], "untracked collapses separately from ignored, but note that matching directories are still emitted, i.e. ignored/" ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &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] diff --git a/gix-dir/tests/walk_utils/mod.rs b/gix-dir/tests/walk_utils/mod.rs index 0d8c3594f8a..909ac60cbc1 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, } } From e91accc8a1e03b04f0ae50c161201e53273f6e03 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 15 Feb 2024 15:45:46 +0100 Subject: [PATCH 06/17] adapt to changes in `gix-dir` --- gix/src/dirwalk.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/gix/src/dirwalk.rs b/gix/src/dirwalk.rs index 693ddc19fce..852de17fec3 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,7 @@ pub struct Options { emit_untracked: EmissionMode, emit_empty_directories: bool, classify_untracked_bare_repositories: bool, + emit_collapsed: Option, } /// Construction @@ -32,6 +33,7 @@ impl Options { emit_untracked: Default::default(), emit_empty_directories: false, classify_untracked_bare_repositories: false, + emit_collapsed: None, } } } @@ -49,6 +51,7 @@ 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, } } } @@ -106,4 +109,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 + } } From 0bef316696fcf11e9f7dc1910819a60e4229f766 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 15 Feb 2024 10:35:59 +0100 Subject: [PATCH 07/17] feat!: improve `index_as_worktree()` function signature. This is accomplished by providing a context which now contains only statically known types, among them also a pathspec search. This allows the attribute stack to be passed and can thus cheaply be cloned, instead of being created internally. --- Cargo.lock | 2 + gix-status/Cargo.toml | 1 + gix-status/src/index_as_worktree/function.rs | 49 ++++++++------- gix-status/src/index_as_worktree/mod.rs | 2 +- gix-status/src/index_as_worktree/types.rs | 23 +++++-- gix-status/src/lib.rs | 18 ------ gix-status/tests/Cargo.toml | 1 + gix-status/tests/status/index_as_worktree.rs | 63 ++++++++++---------- 8 files changed, 84 insertions(+), 75 deletions(-) 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/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") } From 366dfb375d1c4844e4b0edb934fa8c7a5c10b9b3 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 15 Feb 2024 16:54:22 +0100 Subject: [PATCH 08/17] adapt to changes in `gix-status` --- gitoxide-core/src/repository/status.rs | 36 ++++++++++---------------- gix/src/pathspec.rs | 11 -------- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/gitoxide-core/src/repository/status.rs b/gitoxide-core/src/repository/status.rs index 021f5960fea..18208b5a8e3 100644 --- a/gitoxide-core/src/repository/status.rs +++ b/gitoxide-core/src/repository/status.rs @@ -46,30 +46,19 @@ pub fn show( } let mut index = repo.index_or_empty()?; let index = gix::threading::make_mut(&mut index); - let pathspec = repo.pathspec( - pathspecs.iter().map(|p| p.as_bstr()), - 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, 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 mut printer = Printer { out, @@ -80,7 +69,12 @@ pub fn show( .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 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 @@ -114,9 +108,7 @@ pub fn show( Submodule, repo.objects.clone().into_arc()?, &mut progress, - pathspec.detach()?, - filter_pipeline, - &gix::interrupt::IS_INTERRUPTED, + ctx, options, )?; diff --git a/gix/src/pathspec.rs b/gix/src/pathspec.rs index a56ad1c321e..86041aa01f8 100644 --- a/gix/src/pathspec.rs +++ b/gix/src/pathspec.rs @@ -191,14 +191,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) - } -} From 57f0a24d78ce43b69631e05688cc7ac900cfe1cb Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 17 Feb 2024 08:37:25 +0100 Subject: [PATCH 09/17] feat: Add `Search::prefix_directory()` and `Search::longest_common_directory()`. That way it's possible to use the common-prefix safely in a situation where a directory is required, while offering the ability to maximize the common prefix at the expense of an additional check to see if the longest possible directory is actually accessible. --- gix-pathspec/src/search/mod.rs | 42 +++++++++++++- gix-pathspec/tests/search/mod.rs | 96 +++++++++++++++++++++++++------- 2 files changed, 118 insertions(+), 20 deletions(-) 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/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(()) } From b6ea37a4d20e008c0b447090992c6aade0191265 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Fri, 16 Feb 2024 16:18:49 +0100 Subject: [PATCH 10/17] feat!: simplify `walk()` signature to compute `root` with pathspec directory. This makes the overall handling more unified, while assuring it's always in the worktree. And as a pathspec directory isn't exactly the same as a user-specified root, it's also possible to override this automation. --- gix-dir/src/walk/function.rs | 33 +- gix-dir/src/walk/mod.rs | 13 +- gix-dir/src/walk/readdir.rs | 14 +- gix-dir/tests/dir_walk_cwd.rs | 15 +- gix-dir/tests/walk/mod.rs | 1045 ++++++++++++++++++------------- gix-dir/tests/walk_utils/mod.rs | 41 +- 6 files changed, 678 insertions(+), 483 deletions(-) diff --git a/gix-dir/src/walk/function.rs b/gix-dir/src/walk/function.rs index 43c8cf76542..88229d9c196 100644 --- a/gix-dir/src/walk/function.rs +++ b/gix-dir/src/walk/function.rs @@ -8,13 +8,13 @@ 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. /// @@ -36,14 +36,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 { + 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( @@ -73,7 +83,7 @@ pub fn walk( let mut state = readdir::State::default(); let _ = readdir::recursive( - root == worktree_root, + true, &mut current, &mut buf, root_info, @@ -95,10 +105,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 623653edc61..59bedd2bcff 100644 --- a/gix-dir/src/walk/mod.rs +++ b/gix-dir/src/walk/mod.rs @@ -200,7 +200,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]. @@ -221,6 +221,15 @@ 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()). @@ -242,8 +251,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 f11d4244476..ff29e5cad60 100644 --- a/gix-dir/src/walk/readdir.rs +++ b/gix-dir/src/walk/readdir.rs @@ -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, + is_top_level: 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(is_top_level); let mut prevent_collapse = false; for entry in entries { let entry = entry.map_err(|err| Error::DirEntry { @@ -118,18 +118,18 @@ 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, is_top_level: bool) -> Mark { Mark { start_index: self.on_hold.len(), - is_worktree_dir, + is_top_level, } } } struct Mark { start_index: usize, - is_worktree_dir: bool, + is_top_level: bool, } impl Mark { @@ -211,7 +211,7 @@ impl Mark { ctx: &mut Context<'_>, delegate: &mut dyn walk::Delegate, ) -> Option { - if self.is_worktree_dir { + if self.is_top_level { return None; } let (mut expendable, mut precious, mut untracked, mut entries, mut matching_entries) = (0, 0, 0, 0, 0); diff --git a/gix-dir/tests/dir_walk_cwd.rs b/gix-dir/tests/dir_walk_cwd.rs index afffaee423f..fd8d54bf403 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, 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/walk/mod.rs b/gix-dir/tests/walk/mod.rs index 3cb521ee517..b7f335fd0b2 100644 --- a/gix-dir/tests/walk/mod.rs +++ b/gix-dir/tests/walk/mod.rs @@ -4,7 +4,7 @@ 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, try_collect_filtered_opts_collect, - EntryExt, Options, + try_collect_filtered_opts_collect_with_root, EntryExt, Options, }; use gix_dir::entry; use gix_dir::entry::Kind::*; @@ -24,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), @@ -39,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, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -54,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -74,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(()) @@ -86,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, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); assert_eq!( out, walk::Outcome { @@ -107,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -133,9 +136,8 @@ fn complex_empty() -> crate::Result { "by default, no empty directory shows up" ); - let (out, entries) = collect(&root, |keep, ctx| { + let (out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -169,7 +171,7 @@ fn complex_empty() -> crate::Result { #[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, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -179,8 +181,8 @@ fn only_untracked() -> crate::Result { } ); assert_eq!( - &entries, - &[ + entries, + [ entry("a", Untracked, File), entry("b", Untracked, File), entry("c", Untracked, File), @@ -190,27 +192,26 @@ fn only_untracked() -> crate::Result { ] ); - let (out, entries) = collect_filtered(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep), Some("d/*")); + let (out, 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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -230,8 +231,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), @@ -246,9 +247,9 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { let root = fixture("only-untracked"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -263,14 +264,14 @@ 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) ], @@ -279,9 +280,9 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -297,19 +298,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)], @@ -319,9 +316,9 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -336,16 +333,20 @@ 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 + 2, }, "collapsing happens just like Git" ); assert_eq!( - &entries, - &[entryps("d", Untracked, Directory, WildcardMatch)], - "wildcard matches allow collapsing directories because Git does" + entries, + [ + entryps("d/a", Untracked, File, WildcardMatch), + entryps("d/b", Untracked, File, WildcardMatch), + entryps("d/d", Untracked, Directory, WildcardMatch), + ], + "wildcard matches on the top-level always show all matching entries. Collapsing of subdirs works as before" ); Ok(()) } @@ -353,7 +354,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, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); assert_eq!( out, walk::Outcome { @@ -363,8 +364,8 @@ fn expendable_and_precious() { } ); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry(".gitignore", Tracked, File), entry("a.o", Ignored(Expendable), File), @@ -387,9 +388,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -410,8 +410,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), @@ -432,9 +432,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -456,8 +455,8 @@ fn expendable_and_precious() { ); assert_eq!( - &entries, - &[ + entries, + [ entry("some-expendable/new", Untracked, File), entry("some-precious/new", Untracked, File), ], @@ -468,7 +467,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, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -477,13 +476,16 @@ 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, 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!( out, walk::Outcome { @@ -493,11 +495,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -516,16 +517,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, entries) = collect(&root, Some(&troot), |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -535,8 +535,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(()) @@ -548,9 +548,9 @@ fn untracked_and_ignored_pathspec_guidance() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -566,14 +566,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" ); } @@ -585,9 +586,9 @@ fn untracked_and_ignored_for_deletion_negative_wildcard_spec() -> crate::Result let root = fixture("subdir-untracked-and-ignored"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -611,8 +612,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), @@ -640,9 +641,9 @@ fn untracked_and_ignored_for_deletion_positive_wildcard_spec() -> crate::Result let root = fixture("subdir-untracked-and-ignored"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -666,8 +667,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), @@ -693,9 +694,9 @@ fn untracked_and_ignored_for_deletion_nonmatching_wildcard_spec() -> crate::Resu let root = fixture("subdir-untracked-and-ignored"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -719,8 +720,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), @@ -744,9 +745,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 { @@ -764,16 +765,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 { @@ -791,8 +792,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), @@ -809,9 +810,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -835,8 +835,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), @@ -850,9 +850,9 @@ fn expendable_and_precious_in_ignored_dir_with_pathspec() -> crate::Result { let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -877,8 +877,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), @@ -906,9 +906,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -928,8 +927,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), @@ -950,9 +949,9 @@ fn untracked_and_ignored() -> crate::Result { let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -975,8 +974,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), @@ -984,9 +983,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1007,14 +1005,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1034,8 +1031,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), @@ -1053,9 +1050,8 @@ fn untracked_and_ignored() -> crate::Result { "objects are aggregated" ); - let (out, entries) = collect(&root, |keep, ctx| { + let (out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1077,8 +1073,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), @@ -1106,9 +1102,9 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1125,24 +1121,26 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { assert_eq!( out, walk::Outcome { - read_dir_calls: 3, + read_dir_calls: 1, returned_entries: entries.len(), - seen_entries: 19, + 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)], + 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, entries) = collect_filtered( + let (out, entries) = try_collect_filtered_opts_collect_with_root( &root, + None, + Some(&root), |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1156,7 +1154,8 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { ) }, Some(spec), - ); + Default::default(), + )?; assert_eq!( out, walk::Outcome { @@ -1167,8 +1166,8 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { ); 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), @@ -1187,13 +1186,99 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { } #[test] -fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crate::Result { +fn untracked_and_ignored_collapse_handling_mixed_with_prefix() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: None, + emit_collapsed: Some(OnStatusMismatch), + ..options() + }, + keep, + ) + }, + Some("d/d"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 11 + }, + "this is not a directory, so the prefix is only 'd', not 'd/d'" + ); + + assert_eq!( + 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)] { + let (out, entries) = collect_filtered( + &root, + None, + |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), + ); + 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, pathspec_match), + entryps("d/d/a.o", Ignored(Expendable), File, pathspec_match), + entryps("d/d/b.o", Ignored(Expendable), File, pathspec_match), + entryps("d/d/generated", Ignored(Expendable), Directory, pathspec_match), + ], + "{spec}: with wildcard matches, it's OK to collapse though" + ); + } + 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( + &root, + None, + |keep, ctx| { + walk( &root, ctx, walk::Options { @@ -1216,8 +1301,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), @@ -1236,9 +1321,9 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crat let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1262,8 +1347,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), @@ -1294,9 +1379,9 @@ fn untracked_and_ignored_collapse_handling_for_deletion_with_prefix_wildcards() let root = fixture("subdir-untracked-and-ignored"); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1313,14 +1398,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(()) @@ -1329,9 +1414,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1353,14 +1437,13 @@ 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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1383,8 +1466,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), @@ -1404,9 +1487,9 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1424,34 +1507,28 @@ 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: 5, }, ); assert_eq!( - &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 - ), + 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" + "everything is filtered down to the pathspec, otherwise it's like before. Not how all-matching 'generated' collapses" ); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1469,15 +1546,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: 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), ], @@ -1486,9 +1563,9 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1506,28 +1583,28 @@ 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: 5, }, ); assert_eq!( - &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), + entries, + [ + entryps("d/d/a", Untracked, File, Prefix), + entryps("d/d/a.o", Ignored(Expendable), File, Prefix), + entryps("d/d/b.o", Ignored(Expendable), File, Prefix), + entryps("d/d/generated", Ignored(Expendable), Directory, Prefix), ], - "a prefix match works similarly, while also listing the dropped content for good measure" + "a prefix match works similarly" ); let (out, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1544,15 +1621,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(()) @@ -1561,9 +1638,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 { @@ -1575,8 +1651,8 @@ fn precious_are_not_expendable() { ) }); assert_eq!( - &entries, - &[ + entries, + [ entry_nokind(".git", DotGit), entry(".gitignore", Tracked, File), entry("a.o", Ignored(Expendable), File), @@ -1589,9 +1665,8 @@ fn precious_are_not_expendable() { ], "just to have an overview" ); - let (out, entries) = collect(&root, |keep, ctx| { + let (out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1613,8 +1688,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), @@ -1626,12 +1701,51 @@ 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( + &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, entries) = collect_filtered( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1648,16 +1762,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), @@ -1667,9 +1781,8 @@ fn precious_are_not_expendable() { ); } - let (out, entries) = collect(&root, |keep, ctx| { + let (out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -1691,8 +1804,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), @@ -1717,9 +1830,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, entries) = collect(root.path(), None, |keep, ctx| { walk( - root.path(), root.path(), ctx, walk::Options { @@ -1738,16 +1850,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, entries) = collect(root.path(), Some(&troot), |keep, ctx| { walk( - &root.path().join(decomposed), root.path(), ctx, walk::Options { @@ -1757,36 +1868,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, 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 { @@ -1795,10 +1911,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(()) @@ -1808,14 +1923,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, entries) = collect(&root, Some(&troot), |keep, ctx| { + walk(&root, ctx, options_emit_all(), keep) }); assert_eq!( out, @@ -1825,8 +1935,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(()) } @@ -1834,11 +1947,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_collect( + let troot = root.join("dir-with-dot-git").join("inside"); + let (out, 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 { @@ -1861,18 +1976,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 troot = root.join("dir-with-dot-git").join("inside"); let (out, entries) = try_collect_filtered_opts_collect( &root, + Some(&troot), |keep, ctx| { walk( - &root.join("dir-with-dot-git").join("inside"), &root, ctx, walk::Options { @@ -1902,24 +2017,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_collect( + 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" ); @@ -1929,27 +2038,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, 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 { @@ -1958,10 +2079,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(()) @@ -1970,28 +2090,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, 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(()) } @@ -1999,7 +2118,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, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); assert_eq!( out, walk::Outcome { @@ -2024,9 +2143,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, 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 { @@ -2036,10 +2161,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(()) @@ -2048,9 +2172,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, 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 { @@ -2060,10 +2190,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(()) @@ -2072,18 +2201,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, 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 { @@ -2092,11 +2225,10 @@ 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(()) @@ -2105,7 +2237,7 @@ fn root_can_be_pruned_early_with_pathspec() -> crate::Result { #[test] fn submodules() -> crate::Result { let root = fixture("multiple-submodules"); - let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options_emit_all(), keep)); + let (out, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep)); assert_eq!( out, walk::Outcome { @@ -2125,7 +2257,8 @@ fn submodules() -> crate::Result { let (out1, entries) = try_collect_filtered_opts_collect( &root, - |keep, ctx| walk(&root, &root, ctx, options_emit_all(), keep), + None, + |keep, ctx| walk(&root, ctx, options_emit_all(), keep), None::<&str>, Options { fresh_index: false, @@ -2140,9 +2273,9 @@ fn submodules() -> crate::Result { let (out2, entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2202,9 +2335,10 @@ fn cancel_with_collection_does_not_fail() -> crate::Result { }; let _out = try_collect_filtered_opts( &root, + None, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2231,24 +2365,27 @@ fn cancel_with_collection_does_not_fail() -> crate::Result { #[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, 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(()) @@ -2258,9 +2395,15 @@ 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, 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 { @@ -2269,11 +2412,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(()) @@ -2283,9 +2425,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2305,7 +2446,6 @@ fn root_with_dir_that_is_tracked_and_ignored() -> crate::Result { seen_entries: 3, } ); - assert_eq!(entries.len(), 3); assert_eq!( entries, @@ -2326,9 +2466,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2357,9 +2496,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2396,19 +2534,26 @@ 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, 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!( out, walk::Outcome { @@ -2417,11 +2562,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" ); } @@ -2432,9 +2576,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 { @@ -2459,9 +2602,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 { @@ -2484,9 +2626,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 { @@ -2515,9 +2656,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 { @@ -2535,9 +2675,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 { @@ -2564,9 +2703,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2596,9 +2734,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2630,9 +2767,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2678,18 +2814,25 @@ 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, 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!( out, @@ -2699,29 +2842,34 @@ 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, 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!( - &entries[0], - &entry(decomposed, Untracked, File), + entries, + [entry(decomposed, Untracked, File)], "if disabled, it stays decomposed as provided" ); Ok(()) @@ -2730,9 +2878,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, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2764,9 +2911,8 @@ fn untracked_and_ignored_collapse_mix() { "ignored collapses separately from untracked" ); - let (out, entries) = collect(&root, |keep, ctx| { + let (out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2799,9 +2945,8 @@ fn untracked_and_ignored_collapse_mix() { "untracked collapses separately from ignored, but note that matching directories are still emitted, i.e. ignored/" ); - let (out, entries) = collect(&root, |keep, ctx| { + let (out, entries) = collect(&root, None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2838,12 +2983,12 @@ fn untracked_and_ignored_collapse_mix() { } #[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, entries) = collect(&root, Some(&troot), |keep, ctx| { walk( - &root.join("dir").join(".GIT").join(dir), &root, ctx, walk::Options { @@ -2861,32 +3006,38 @@ 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, 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!( - &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] @@ -2894,15 +3045,15 @@ 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, 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 { @@ -2911,11 +3062,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." ); @@ -2928,9 +3077,9 @@ fn type_mismatch() { let root = fixture("type-mismatch"); let (out, entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2956,7 +3105,6 @@ fn type_mismatch() { seen_entries: 3, } ); - assert_eq!(entries.len(), 2); assert_eq!( entries, @@ -2971,9 +3119,9 @@ fn type_mismatch() { let (out, entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -2999,7 +3147,6 @@ fn type_mismatch() { seen_entries: 3 + 1, } ); - assert_eq!(entries.len(), 2); assert_eq!( entries, @@ -3016,9 +3163,9 @@ fn type_mismatch_ignore_case() { let root = fixture("type-mismatch-icase"); let (out, entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -3056,9 +3203,9 @@ fn type_mismatch_ignore_case() { let (out, entries) = try_collect_filtered_opts_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -3100,9 +3247,9 @@ 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_collect( &root, + None, |keep, ctx| { walk( - &root, &root, ctx, walk::Options { @@ -3141,9 +3288,9 @@ 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_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 909ac60cbc1..9d789a16851 100644 --- a/gix-dir/tests/walk_utils/mod.rs +++ b/gix-dir/tests/walk_utils/mod.rs @@ -145,47 +145,75 @@ impl EntryExt for (Entry, Option) { pub fn collect( worktree_root: &Path, + root: Option<&Path>, cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, ) -> (walk::Outcome, Entries) { - try_collect(worktree_root, cb).unwrap() + try_collect(worktree_root, root, cb).unwrap() } pub fn collect_filtered( worktree_root: &Path, + root: Option<&Path>, cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, patterns: impl IntoIterator>, ) -> (walk::Outcome, Entries) { - try_collect_filtered(worktree_root, cb, patterns).unwrap() + try_collect_filtered(worktree_root, root, cb, patterns).unwrap() } pub fn try_collect( worktree_root: &Path, + root: Option<&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>) + try_collect_filtered(worktree_root, root, cb, None::<&str>) } pub fn try_collect_filtered( worktree_root: &Path, + root: Option<&Path>, cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, patterns: impl IntoIterator>, ) -> Result<(walk::Outcome, Entries), walk::Error> { - try_collect_filtered_opts_collect(worktree_root, cb, patterns, Default::default()) + 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, patterns: impl IntoIterator>, options: Options<'_>, ) -> Result<(walk::Outcome, Entries), walk::Error> { let mut dlg = gix_dir::walk::delegate::Collect::default(); - let outcome = try_collect_filtered_opts(worktree_root, cb, patterns, &mut dlg, options)?; + let outcome = try_collect_filtered_opts(worktree_root, root, 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, + patterns: impl IntoIterator>, + options: Options<'_>, +) -> Result<(walk::Outcome, Entries), walk::Error> { + let mut dlg = gix_dir::walk::delegate::Collect::default(); + let outcome = try_collect_filtered_opts( + worktree_root, + root, + explicit_traversal_root, + cb, + patterns, + &mut dlg, + options, + )?; Ok((outcome, dlg.into_entries_by_path())) } pub fn try_collect_filtered_opts( worktree_root: &Path, + root: Option<&Path>, + explicit_traversal_root: Option<&Path>, cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, patterns: impl IntoIterator>, delegate: &mut dyn gix_dir::walk::Delegate, @@ -223,7 +251,7 @@ 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")), "we don't provide absolute pathspecs, thus need no worktree root".as_ref(), ) .expect("search creation can't fail"); @@ -254,6 +282,7 @@ 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, }, ) } From 44ccc67a5b4a481f769399c41f0d3fc956fd8ec8 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 17 Feb 2024 15:08:15 +0100 Subject: [PATCH 11/17] more natural top-level handling - when deleting items, we will always list them according to the prefix - when listing items otherwise, the top-level only if the traversal starts in the worktree. --- gix-dir/src/walk/function.rs | 21 +- gix-dir/src/walk/mod.rs | 4 +- gix-dir/src/walk/readdir.rs | 72 ++++- gix-dir/tests/dir_walk_cwd.rs | 2 +- gix-dir/tests/walk/mod.rs | 550 ++++++++++++++++++++++++++------ gix-dir/tests/walk_utils/mod.rs | 67 +++- 6 files changed, 575 insertions(+), 141 deletions(-) diff --git a/gix-dir/src/walk/function.rs b/gix-dir/src/walk/function.rs index 88229d9c196..1017d1d3451 100644 --- a/gix-dir/src/walk/function.rs +++ b/gix-dir/src/walk/function.rs @@ -18,6 +18,10 @@ use crate::{entry, EntryRef}; /// * `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 @@ -40,7 +44,7 @@ pub fn walk( 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 @@ -78,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( - true, + 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, @@ -93,8 +98,12 @@ pub fn walk( &mut out, &mut state, )?; + 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`, diff --git a/gix-dir/src/walk/mod.rs b/gix-dir/src/walk/mod.rs index 59bedd2bcff..3f593c40d9b 100644 --- a/gix-dir/src/walk/mod.rs +++ b/gix-dir/src/walk/mod.rs @@ -182,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. /// @@ -233,7 +235,7 @@ pub struct Context<'a> { } /// 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, diff --git a/gix-dir/src/walk/readdir.rs b/gix-dir/src/walk/readdir.rs index ff29e5cad60..64db84c58c2 100644 --- a/gix-dir/src/walk/readdir.rs +++ b/gix-dir/src/walk/readdir.rs @@ -1,6 +1,6 @@ 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}; @@ -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_top_level: 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_top_level); + let mark = state.mark(may_collapse); let mut prevent_collapse = false; for entry in entries { let entry = entry.map_err(|err| Error::DirEntry { @@ -64,8 +64,18 @@ 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 { return Ok((action, prevent_collapse)); @@ -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 { @@ -119,17 +130,58 @@ impl State { /// Keep track of state we need to later resolve the state. /// Top-level directories are special, as they don't fold. - fn mark(&self, is_top_level: bool) -> Mark { + fn mark(&self, may_collapse: bool) -> Mark { Mark { start_index: self.on_hold.len(), - is_top_level, + 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_top_level: bool, + may_collapse: bool, } impl Mark { @@ -211,7 +263,7 @@ impl Mark { ctx: &mut Context<'_>, delegate: &mut dyn walk::Delegate, ) -> Option { - if self.is_top_level { + if !self.may_collapse { return None; } let (mut expendable, mut precious, mut untracked, mut entries, mut matching_entries) = (0, 0, 0, 0, 0); diff --git a/gix-dir/tests/dir_walk_cwd.rs b/gix-dir/tests/dir_walk_cwd.rs index fd8d54bf403..a05c35d28ca 100644 --- a/gix-dir/tests/dir_walk_cwd.rs +++ b/gix-dir/tests/dir_walk_cwd.rs @@ -13,7 +13,7 @@ fn prefixes_work_as_expected() -> gix_testtools::Result { let root = fixture("only-untracked"); std::env::set_current_dir(root.join("d"))?; let troot = Path::new("..").join("d"); - let (out, entries) = collect(Path::new(".."), Some(&troot), |keep, ctx| { + let ((out, _root), entries) = collect(Path::new(".."), Some(&troot), |keep, ctx| { walk(Path::new(".."), ctx, options(), keep) }); assert_eq!( diff --git a/gix-dir/tests/walk/mod.rs b/gix-dir/tests/walk/mod.rs index b7f335fd0b2..5cc87ab305b 100644 --- a/gix-dir/tests/walk/mod.rs +++ b/gix-dir/tests/walk/mod.rs @@ -2,9 +2,9 @@ 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, try_collect_filtered_opts_collect, - try_collect_filtered_opts_collect_with_root, 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::*; @@ -45,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, None, |keep, ctx| walk(&root, ctx, options(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -60,7 +60,7 @@ fn empty_root() -> crate::Result { "by default, nothing is shown as the directory is empty" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -90,7 +90,7 @@ fn empty_root() -> crate::Result { #[test] fn complex_empty() -> crate::Result { let root = fixture("complex-empty"); - let (out, entries) = collect(&root, None, |keep, ctx| walk(&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 { @@ -111,7 +111,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -136,7 +136,7 @@ fn complex_empty() -> crate::Result { "by default, no empty directory shows up" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -168,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, None, |keep, ctx| walk(&root, ctx, options(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -192,7 +475,8 @@ fn only_untracked() -> crate::Result { ] ); - let (out, entries) = collect_filtered(&root, None, |keep, ctx| walk(&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 { @@ -210,7 +494,7 @@ fn only_untracked() -> crate::Result { ] ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -245,7 +529,7 @@ 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| { @@ -278,7 +562,7 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { "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| { @@ -314,7 +598,7 @@ 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| { @@ -335,18 +619,14 @@ fn only_untracked_explicit_pathspec_selection() -> crate::Result { walk::Outcome { read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 2 + 2, + seen_entries: 2 + 3, }, "collapsing happens just like Git" ); assert_eq!( entries, - [ - entryps("d/a", Untracked, File, WildcardMatch), - entryps("d/b", Untracked, File, WildcardMatch), - entryps("d/d", Untracked, Directory, WildcardMatch), - ], - "wildcard matches on the top-level always show all matching entries. Collapsing of subdirs works as before" + [entryps("d", Untracked, Directory, WildcardMatch),], + "wildcard matches on the top-level without deletion show just the top level" ); Ok(()) } @@ -354,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, None, |keep, ctx| walk(&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 { @@ -388,7 +668,7 @@ fn expendable_and_precious() { "listing everything is a 'matching' preset, which is among the most efficient." ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -432,7 +712,7 @@ fn expendable_and_precious() { those with all files of one type will be collapsed though" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -467,7 +747,7 @@ fn expendable_and_precious() { #[test] fn subdir_untracked() -> crate::Result { let root = fixture("subdir-untracked"); - let (out, entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); + let ((out, _root), entries) = collect(&root, None, |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -478,7 +758,7 @@ fn subdir_untracked() -> crate::Result { ); assert_eq!(entries, [entry("d/d/a", Untracked, File)]); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&root), @@ -486,6 +766,7 @@ fn subdir_untracked() -> crate::Result { Some("d/d/*"), Default::default(), )?; + assert_eq!(actual_root, root); assert_eq!( out, walk::Outcome { @@ -497,7 +778,7 @@ fn subdir_untracked() -> crate::Result { ); assert_eq!(entries, &[entryps("d/d/a", Untracked, File, WildcardMatch)]); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -525,7 +806,7 @@ fn subdir_untracked() -> crate::Result { fn only_untracked_from_subdir() -> crate::Result { let root = fixture("only-untracked"); let troot = root.join("d").join("d"); - let (out, entries) = collect(&root, Some(&troot), |keep, ctx| walk(&root, ctx, options(), keep)); + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| walk(&root, ctx, options(), keep)); assert_eq!( out, walk::Outcome { @@ -546,7 +827,7 @@ 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| { @@ -584,7 +865,7 @@ 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| { @@ -639,7 +920,7 @@ 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| { @@ -692,7 +973,7 @@ 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| { @@ -810,7 +1091,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -848,7 +1129,7 @@ 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| { @@ -906,7 +1187,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -947,7 +1228,7 @@ fn untracked_and_ignored() -> crate::Result { ] ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, None, |keep, ctx| { @@ -983,7 +1264,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -1010,7 +1291,7 @@ fn untracked_and_ignored() -> crate::Result { "aggregation kicks in here" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -1050,7 +1331,7 @@ fn untracked_and_ignored() -> crate::Result { "objects are aggregated" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -1100,7 +1381,7 @@ fn untracked_and_ignored() -> crate::Result { #[test] fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { let root = fixture("subdir-untracked-and-ignored"); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, None, |keep, ctx| { @@ -1135,7 +1416,7 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { ); for (spec, pathspec_match) in [("d/d/*", WildcardMatch), ("d/d", Prefix), ("d/d/", Prefix)] { - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&root), @@ -1188,7 +1469,7 @@ fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { #[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| { @@ -1229,7 +1510,7 @@ fn untracked_and_ignored_collapse_handling_mixed_with_prefix() -> crate::Result ); for (spec, pathspec_match) in [("d/d/*", WildcardMatch), ("d/d/", Prefix)] { - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, None, |keep, ctx| { @@ -1253,28 +1534,35 @@ fn untracked_and_ignored_collapse_handling_mixed_with_prefix() -> crate::Result walk::Outcome { read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 5, + seen_entries: 6, }, ); assert_eq!( entries, [ - entryps("d/d/a", Untracked, File, pathspec_match), - entryps("d/d/a.o", Ignored(Expendable), File, pathspec_match), - entryps("d/d/b.o", Ignored(Expendable), File, pathspec_match), - entryps("d/d/generated", Ignored(Expendable), Directory, pathspec_match), + 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 + ), ], "{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| { @@ -1319,7 +1607,7 @@ 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| { @@ -1377,7 +1665,7 @@ 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| { @@ -1414,7 +1702,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -1442,7 +1730,7 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result "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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -1485,7 +1773,7 @@ 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| { @@ -1504,6 +1792,48 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result }, Some("d/d/*"), ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 6, + }, + ); + + assert_eq!( + 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), + ], + "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 { @@ -1521,10 +1851,10 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result 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 'generated' 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| { @@ -1561,7 +1891,7 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result "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| { @@ -1585,22 +1915,23 @@ fn untracked_and_ignored_collapse_handling_for_deletion_mixed() -> crate::Result walk::Outcome { read_dir_calls: 2, returned_entries: entries.len(), - seen_entries: 5, + seen_entries: 6, }, ); assert_eq!( entries, [ - entryps("d/d/a", Untracked, File, Prefix), - entryps("d/d/a.o", Ignored(Expendable), File, Prefix), - entryps("d/d/b.o", Ignored(Expendable), File, Prefix), - entryps("d/d/generated", Ignored(Expendable), Directory, Prefix), + 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" + "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| { @@ -1665,7 +1996,7 @@ fn precious_are_not_expendable() { ], "just to have an overview" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -1701,7 +2032,7 @@ fn precious_are_not_expendable() { a collapsed precious file." ); - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, None, |keep, ctx| { @@ -1741,7 +2072,7 @@ fn precious_are_not_expendable() { "should yield the same entries - note how collapsed directories inherit the pathspec" ); for (equivalent_pathspec, expected_match) in [("d/*", WildcardMatch), ("d/", Prefix)] { - let (out, entries) = collect_filtered( + let ((out, _root), entries) = collect_filtered( &root, None, |keep, ctx| { @@ -1781,7 +2112,7 @@ fn precious_are_not_expendable() { ); } - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -1830,7 +2161,7 @@ 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(), None, |keep, ctx| { + let ((out, _root), entries) = collect(root.path(), None, |keep, ctx| { walk( root.path(), ctx, @@ -1857,7 +2188,7 @@ fn decomposed_unicode_in_directory_is_returned_precomposed() -> crate::Result { ); let troot = root.path().join(decomposed); - let (out, entries) = collect(root.path(), Some(&troot), |keep, ctx| { + let ((out, _root), entries) = collect(root.path(), Some(&troot), |keep, ctx| { walk( root.path(), ctx, @@ -1895,7 +2226,7 @@ fn decomposed_unicode_in_directory_is_returned_precomposed() -> crate::Result { fn worktree_root_can_be_symlink() -> crate::Result { let root = fixture_in("many-symlinks", "symlink-to-breakout-symlink"); let troot = root.join("file"); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -1924,7 +2255,7 @@ fn root_may_not_go_through_dot_git() -> crate::Result { let root = fixture("with-nested-dot-git"); for dir in ["", "subdir"] { let troot = root.join("dir").join(".git").join(dir); - let (out, entries) = collect(&root, Some(&troot), |keep, ctx| { + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| { walk(&root, ctx, options_emit_all(), keep) }); assert_eq!( @@ -1948,7 +2279,7 @@ fn root_may_not_go_through_dot_git() -> crate::Result { fn root_enters_directory_with_dot_git_in_reconfigured_worktree_tracked() -> crate::Result { let root = fixture("nonstandard-worktree"); let troot = root.join("dir-with-dot-git").join("inside"); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -1983,7 +2314,7 @@ fn root_enters_directory_with_dot_git_in_reconfigured_worktree_tracked() -> crat ); let troot = root.join("dir-with-dot-git").join("inside"); - let (out, entries) = try_collect_filtered_opts_collect( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, Some(&troot), |keep, ctx| { @@ -2063,7 +2394,7 @@ fn root_may_not_go_through_nested_repository_unless_enabled() -> crate::Result { "it happily enters the repository and lists the file" ); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -2091,7 +2422,7 @@ fn root_may_not_go_through_nested_repository_unless_enabled() -> crate::Result { fn root_may_not_go_through_submodule() -> crate::Result { let root = fixture("with-submodule"); let troot = root.join("submodule").join("dir").join("file"); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -2118,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, None, |keep, ctx| walk(&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 { @@ -2144,7 +2475,7 @@ fn walk_with_submodule() -> crate::Result { fn root_that_is_tracked_file_is_returned() -> crate::Result { let root = fixture("dir-with-tracked-file"); let troot = &root.join("dir").join("file"); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(troot), @@ -2173,7 +2504,7 @@ fn root_that_is_tracked_file_is_returned() -> crate::Result { fn root_that_is_untracked_file_is_returned() -> crate::Result { let root = fixture("dir-with-file"); let troot = root.join("dir").join("file"); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -2209,7 +2540,7 @@ fn top_level_root_that_is_a_file() { fn root_can_be_pruned_early_with_pathspec() -> crate::Result { let root = fixture("dir-with-file"); let troot = root.join("dir"); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -2237,7 +2568,7 @@ fn root_can_be_pruned_early_with_pathspec() -> crate::Result { #[test] fn submodules() -> crate::Result { let root = fixture("multiple-submodules"); - let (out, entries) = collect(&root, None, |keep, ctx| walk(&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 { @@ -2255,7 +2586,7 @@ fn submodules() -> crate::Result { ]; assert_eq!(entries, expected_content, "submodules are detected as repositories"); - let (out1, entries) = try_collect_filtered_opts_collect( + let ((out1, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| walk(&root, ctx, options_emit_all(), keep), @@ -2271,7 +2602,7 @@ fn submodules() -> crate::Result { "this is also the case if the index isn't considered fresh" ); - let (out2, entries) = try_collect_filtered_opts_collect( + let ((out2, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| { @@ -2337,6 +2668,7 @@ fn cancel_with_collection_does_not_fail() -> crate::Result { &root, None, None, + None, |keep, ctx| { walk( &root, @@ -2366,7 +2698,7 @@ fn cancel_with_collection_does_not_fail() -> crate::Result { fn file_root_is_shown_if_pathspec_matches_exactly() -> crate::Result { let root = fixture("dir-with-file"); let troot = root.join("dir").join("file"); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, _root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -2396,7 +2728,7 @@ fn root_that_is_tracked_and_ignored_is_considered_tracked() -> crate::Result { let root = fixture("tracked-is-ignored"); let walk_root = "dir/file"; let troot = root.join(walk_root); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -2404,6 +2736,7 @@ fn root_that_is_tracked_and_ignored_is_considered_tracked() -> crate::Result { None::<&str>, Default::default(), )?; + assert_eq!(actual_root, troot, "it uses the root we provide"); assert_eq!( out, walk::Outcome { @@ -2425,7 +2758,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2466,7 +2799,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2496,7 +2829,7 @@ fn empty_and_nested_untracked() -> crate::Result { ], "we find all untracked entries, no matter the deletion mode" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2536,7 +2869,7 @@ fn root_that_is_ignored_is_listed_for_files_and_directories() -> crate::Result { for walk_root in ["dir", "dir/file"] { let troot = root.join(walk_root); for emission in [Matching, CollapseDirectory] { - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -2554,6 +2887,7 @@ fn root_that_is_ignored_is_listed_for_files_and_directories() -> crate::Result { None::<&str>, Default::default(), )?; + assert_eq!(actual_root, troot); assert_eq!( out, walk::Outcome { @@ -2703,7 +3037,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2734,7 +3068,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2767,7 +3101,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2815,7 +3149,7 @@ fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { std::fs::write(root.path().join(decomposed), [])?; let troot = root.path().join(decomposed); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( root.path(), None, Some(&troot), @@ -2834,6 +3168,7 @@ fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { Default::default(), )?; + assert_eq!(actual_root, troot); assert_eq!( out, walk::Outcome { @@ -2849,7 +3184,7 @@ fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { ); let troot = root.path().join(decomposed); - let (_out, entries) = try_collect_filtered_opts_collect_with_root( + let ((_out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( root.path(), None, Some(&troot), @@ -2867,6 +3202,7 @@ fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { None::<&str>, Default::default(), )?; + assert_eq!(actual_root, troot); assert_eq!( entries, [entry(decomposed, Untracked, File)], @@ -2878,7 +3214,7 @@ 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, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2911,7 +3247,7 @@ fn untracked_and_ignored_collapse_mix() { "ignored collapses separately from untracked" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2945,7 +3281,7 @@ fn untracked_and_ignored_collapse_mix() { "untracked collapses separately from ignored, but note that matching directories are still emitted, i.e. ignored/" ); - let (out, entries) = collect(&root, None, |keep, ctx| { + let ((out, _root), entries) = collect(&root, None, |keep, ctx| { walk( &root, ctx, @@ -2987,7 +3323,7 @@ fn root_cannot_pass_through_case_altered_capital_dot_git_if_case_insensitive() - let root = fixture("with-nested-capitalized-dot-git"); for dir in ["", "subdir"] { let troot = root.join("dir").join(".GIT").join(dir); - let (out, entries) = collect(&root, Some(&troot), |keep, ctx| { + let ((out, _root), entries) = collect(&root, Some(&troot), |keep, ctx| { walk( &root, ctx, @@ -3014,7 +3350,7 @@ fn root_cannot_pass_through_case_altered_capital_dot_git_if_case_insensitive() - } let troot = root.join("dir").join(".GIT").join("config"); - let (_out, entries) = try_collect_filtered_opts_collect_with_root( + let ((_out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -3032,6 +3368,7 @@ fn root_cannot_pass_through_case_altered_capital_dot_git_if_case_insensitive() - None::<&str>, Default::default(), )?; + assert_eq!(actual_root, troot); assert_eq!( entries, [entry("dir/.GIT/config", Untracked, File)], @@ -3046,7 +3383,7 @@ fn partial_checkout_cone_and_non_one() -> crate::Result { let root = fixture(fixture_name); let not_in_cone_but_created_locally_by_hand = "d/file-created-manually"; let troot = root.join(not_in_cone_but_created_locally_by_hand); - let (out, entries) = try_collect_filtered_opts_collect_with_root( + let ((out, actual_root), entries) = try_collect_filtered_opts_collect_with_root( &root, None, Some(&troot), @@ -3054,6 +3391,7 @@ fn partial_checkout_cone_and_non_one() -> crate::Result { None::<&str>, Default::default(), )?; + assert_eq!(actual_root, troot); assert_eq!( out, walk::Outcome { @@ -3075,7 +3413,7 @@ 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_collect( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| { @@ -3117,7 +3455,7 @@ 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_collect( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| { @@ -3161,7 +3499,7 @@ fn type_mismatch() { #[test] fn type_mismatch_ignore_case() { let root = fixture("type-mismatch-icase"); - let (out, entries) = try_collect_filtered_opts_collect( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| { @@ -3201,7 +3539,7 @@ 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_collect( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| { @@ -3245,7 +3583,7 @@ 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_collect( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| { @@ -3286,7 +3624,7 @@ 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_collect( + let ((out, _root), entries) = try_collect_filtered_opts_collect( &root, None, |keep, ctx| { diff --git a/gix-dir/tests/walk_utils/mod.rs b/gix-dir/tests/walk_utils/mod.rs index 9d789a16851..aef8ef5bd3c 100644 --- a/gix-dir/tests/walk_utils/mod.rs +++ b/gix-dir/tests/walk_utils/mod.rs @@ -146,46 +146,46 @@ impl EntryExt for (Entry, Option) { pub fn collect( worktree_root: &Path, root: Option<&Path>, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, -) -> (walk::Outcome, Entries) { + 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, root: Option<&Path>, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, patterns: impl IntoIterator>, -) -> (walk::Outcome, Entries) { +) -> ((walk::Outcome, PathBuf), Entries) { try_collect_filtered(worktree_root, root, cb, patterns).unwrap() } pub fn try_collect( worktree_root: &Path, root: Option<&Path>, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, -) -> Result<(walk::Outcome, Entries), walk::Error> { + 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, root: Option<&Path>, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, patterns: impl IntoIterator>, -) -> Result<(walk::Outcome, Entries), walk::Error> { +) -> 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, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, patterns: impl IntoIterator>, options: Options<'_>, -) -> Result<(walk::Outcome, Entries), walk::Error> { +) -> 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, cb, patterns, &mut dlg, options)?; + let outcome = try_collect_filtered_opts(worktree_root, root, None, None, cb, patterns, &mut dlg, options)?; Ok((outcome, dlg.into_entries_by_path())) } @@ -193,15 +193,16 @@ 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, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result<(walk::Outcome, PathBuf), walk::Error>, patterns: impl IntoIterator>, options: Options<'_>, -) -> Result<(walk::Outcome, Entries), walk::Error> { +) -> 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, @@ -210,15 +211,39 @@ pub fn try_collect_filtered_opts_collect_with_root( 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, root: Option<&Path>, explicit_traversal_root: Option<&Path>, - cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + 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 { +) -> 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), @@ -251,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") }), - root.map(|root| root.strip_prefix(worktree_root).expect("root is within worktree root")), + 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"); @@ -268,7 +294,14 @@ 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 lookup = index.prepare_icase_backing(); cb( From ab0f63aa5ab90c3a18a62e72d486a889b540d804 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Fri, 16 Feb 2024 20:22:42 +0100 Subject: [PATCH 12/17] adapt to changes in `gix-dir` --- gitoxide-core/src/repository/clean.rs | 2 +- gix/src/repository/dirwalk.rs | 9 +++++---- gix/src/repository/filter.rs | 2 +- .../generated-archives/make_basic_repo.tar.xz | Bin 11596 -> 11932 bytes gix/tests/fixtures/make_basic_repo.sh | 7 +++++++ gix/tests/repository/filter.rs | 9 ++++++++- gix/tests/repository/mod.rs | 1 + 7 files changed, 23 insertions(+), 7 deletions(-) diff --git a/gitoxide-core/src/repository/clean.rs b/gitoxide-core/src/repository/clean.rs index 071be245b89..be7c6b0ff66 100644 --- a/gitoxide-core/src/repository/clean.rs +++ b/gitoxide-core/src/repository/clean.rs @@ -55,7 +55,7 @@ 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 = InterruptableCollect::default(); let collapse_directories = CollapseDirectory; diff --git a/gix/src/repository/dirwalk.rs b/gix/src/repository/dirwalk.rs index 6d47247f6b3..325e89ec624 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 std::path::PathBuf; /// The error returned by [dirwalk()](Repository::dirwalk()). #[derive(Debug, thiserror::Error)] @@ -42,7 +42,8 @@ impl Repository { patterns: impl IntoIterator>, options: dirwalk::Options, delegate: &mut dyn gix_dir::walk::Delegate, - ) -> Result { + ) -> Result<(gix_dir::walk::Outcome, PathBuf), Error> { + let _span = gix_trace::coarse!("gix::dirwalk"); let workdir = self.work_dir().ok_or(Error::MissinWorkDir)?; let mut excludes = self .excludes( @@ -59,14 +60,13 @@ impl Repository { crate::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, )? .into_parts(); + gix_trace::debug!(patterns = ?pathspec.patterns().map(|p| p.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), workdir, gix_dir::walk::Context { git_dir_realpath: git_dir_realpath.as_ref(), @@ -85,6 +85,7 @@ impl Repository { }, excludes: Some(&mut excludes), objects: &self.objects, + explicit_traversal_root: None, }, options.into(), delegate, 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/tests/fixtures/generated-archives/make_basic_repo.tar.xz b/gix/tests/fixtures/generated-archives/make_basic_repo.tar.xz index af6aa912b60b2ac145d87798bc62ab2e9498be23..dc37073d9cdc3f07331328b5b666ebf0bd649f68 100644 GIT binary patch delta 11811 zcmV+;F5J<~TAW>w83e`uE?bcu$A7OAo1621LMSO`1h21KSj_F~END165^S8MED!gUaJ2OOibUF@b}TmUek?NPW01vggDQiRQ8jHrNm-xlTJ<+Wnd zr+ye3xF6}%0HTxQ+b)3?)Y_gcnf0MBtwv*Eiqy~zn!3ej3%ReQ(uj4rohdqBYf*0V z)nEZ;78CXZ(L_1~hM(!kzkl%|g?uP4x-iuqNN(Kn@wRDYLe69r1%hHC^V);p^NegFR66U0vKFiT|1sTz&d$!ac8KSjxcSm&MT+F`HQMAe ziqnU(HpPT2VRUs1;7tS(?n@_ zdM5N;o=l7P#VZT&DJ{O|Yl-lr48`BCkA%i*@i_5i4B*&k5V{ibs1o|E5|?f4jDKY) zBf<{=KndV5b_EKx)Dr*4w8bO)h+nRZCm>)Nq>bYJ4UyAHykI9rCgIuYJE3e;XN+Uo7Qce}5(#Um+Ms5^!zS)vskO8c-(466COi`W9B z+;fF{L!tpLaed;*-+UF}z5JmmD%e93?_US4T+c}qwj`r}JSfJPO4IB_S`pKi;EQRB zX%xAk4hLd!=5DHx)`R_IHUO_45&U(#c=F~)lpAT>oQl-!pML{XOBruP5j*V0)t^fc z)sX8tNb;qtHQ!^>I95OX)ch3C?M>&&yMBgi=>Epi=a*Mxj2txHJUxLVu%G!M*qG-0tB0HVcm;86i+mOU2vr{^|DsNbf5c%gk9i6s}MF$A8+B zD$lOZKiP&FG8Ao+OBcb5X!EAY?8vrcE63*Xi3r??)j%7CnVapedr2Wt(!)g2!x`^Kq9NmaRgn~TgM3Xc9Wce zfbE^jZEGUf+EMuq1hur)_f_*T(kxl4=I8c(>)VK7`?vGmdQLa2bb|P5^WR9fxC5G- z5X?|;eSg!WPl6--HnXakvfS!v^z+)T+bb(&vX-Kk!&)FCA%KJhPQ>Aowpn}&|FMi? zYlh=tsE}YR&51BiNGxXl8Rarz0B6mJ!5u4DoZgN1(xue@I7S49bP(`EAD*GMUxB0q z5{v%kKpohh5cocx0bzVAnvdw7Y;t-jhS|j=dVeUI$I6rS{~I~VQ*1Bsfx4}&Q-(eh z__(w?+o~>aADEbK8aocdp>wx3UcPB|XMvEn0P$IcK zWj=M`SRiKMCujclC+3BL)0i!bk|NU~{ z4&w)W5waJQe-Z|ii-utOX64Y2^W^}R0rQC0>)Cby3y zV07uBoc6N^GHyP|qWOdFaR?BgUjrUEk}IU$AXNEd?|8y3HmN(bJ+|UUzjGYQ@^5{f zMR{z;>FX9am>r}whFeu-+6ksObgRW6HPT7IU~oyL!|~uY zwFl@EJr(pl8iD^(eRvp^?)_2~!_@<)dzs5XvS(G(yDVbfl+@>Rvs(X3GEq8Ohj~=3ndLE>+4kX+mSLES|SbwHKXa(rQ9cQ#| zNq7K>A0^%f!c9f|IvZzux#r=DrXb@{AT0Utnc)ckigNqcMcv2x)fKe&8z`9+A9K^2 zvCwq#YU;`@ouzJO)hrPCi^;(0_t)7mhwTdKVUsxtJRd;kuFTFHKGP)_<&XRCZ49?+8Q7lvW4; zMo5{x*7q`QrUgC=oI=>L^V;@BIde{?4|XBsFsl_D8OZWAmpqmc+!kY&r+Blj0O2;( zMWN&7=8%j>%PN_$?sb>6^ZA-IIxPzekc<7$f^otD8&dm2nSY3z^v0{f6f~j*4G-RO zEqz+w7&_B9&P5&`Z+H2Z&XkXwSC(n0m;?;;qGV#_6FXgK{^7LP{wM#*cxVy?tf$J3V7FxREA=8uj=4qsZJzH zRvdi=tzl3K%Lk5?zM~U1)#FuRC5y|<8tnu(Pl%W;3aow@`^)zqzRvw?$PW6FOTiR4Up=*3761BI}-Adr#y4_eQshH*V3j-?h5X?B4y4tQ2{7$DS!V00hNXa%~ErZtBl*U3p1kH@q4t5 zbpIu`G4jL*Y;wHWg5aAXZ~dkRcI`H`a=GlYuE4hChWB06YGk1V%H8XC{}^9TLz|cz z{|3{sh3z&yZ@3VIeM36ol6ZT-J*X3Pb*8Y7S(nHv0@xILtoZ~(OyT04O-D;7WaQBhj9?4SC93h$YcnTAt&ETO;;4RNimxJLI;bpkqR( z&;O!G$3BihIPdc;aE10F}oO&7xU@Zx5eRNWDNR=jRf8GkzM zhnFz!?)o*5r}Zpqb*0z1MG*Fj>0piBCR-Udf0U1o)6MV}_-h7u(Bu9s_A33*65(q} zxWt5kjk4m!hj5O>Q3_$uSd3MWXnt9$WTcD0+-E7{Ue7J6Oyr5HEsX!#kP#EY5N~r- z4<~fD6UqfU_(MXse;)EHTZ%e*B!9;B6zfsLGu5|_M}j6M6tXT<|Au@tZD7qSn0z21 z1`cyyKtv73?yl~vQYB{T<^KE)t+a;QD3Mjv7qM~)BbAojllfXJoSbL<$CZZG$zJwX<-R2C?@Z_r%RJDJwhpTpNQ+b$<@MfVPFh z%xlNuSmpbsfRQO_F1oukCrTI^kkr+CPGy2aRrgKKLwl*3vnu)`026B$1R9(?LeE~- z4O-wCdX=Hc?MFNFh`j7PqMkH|i~QU*p9W_UqX>qX-3M|=%BOPBgFd(JXu~Re0Errv zsl(m)gIpzR2l>ER#fNTa4mmJ?U6g+f&CTU zmbWddFN3ZmYWe@#vn*fjF*c_6x(F9e97bNIV7A+_{hxylx!mMmnl9(H&YBT{66n-w z;yHY@mAFkiSj6Eg+|v1<&8%04?qa0$yyyFDI5dK_TonDY2BVq%u)-1H z>O`MU>SlGzHDB-3iV7;8v*~4ju{56a>d7i&thrNB7Dhe!y)2>#9eQ0de5A`CP7}F! z?M{B8YfN{hN3E3YCe3tnp-U}kixXJ%coW8{G97@K)00b`isDwPrlNApM5L2tVo0PR zycJ`RUj>mMmVadldM6PR4ZY>3Sd9!8p43qw`=#{0(0nbCZr_~_pzRGbpKxv>ooHUQ zled&qQfQSlsCD(=n=xwQ(*cLsXT#!VVz={N$MjKjt@LB}EsNTTQw~`N!<*q@<@zeR ze^7qq+Vxu-ut z`LsHc5`SW?HY?-pW9;2Y#YOXPJtPg8dSQcsl3P38q~5CErT0cAbBz`1&jCgB5ID>D zVHiANSJ5LzkR5-Bg$-6mJcVAkgz4#ms{+R_&pMV(RbInN`h|pnU%zHuYF660i2wW7 zxy=K0;BRz>F4Yo!ZUvl{6!c5Ka8y^|nNW#>3xC7X5oO>1ET&k1TOV4i>|1%TIWzC> z!uLTQW=#Q-BX?52#Z+{NbY_SY-+m=S5|vMwrmTo%(?_HLJFMMBS^;KDjByY&ZGT&rd+P;WJZA|L*$6O4mU;gafY>EJ?9AN!AP zXi_D}*MC>h zRTyobu;ij7v%_M~fODjtCvWPz_oGQDKxxA=FQ!;vF*s+g62WVt_*nZ(ZqK%U9a%VG zoZ=dvE=sJpa@dovYwhPY_C3-kqB4ZDVKYOmI7gaggqnk>dzWwYoyFo#Kf}e}2>ivn z1gyhX#5Ypt>P<<&5Y*c~CA{qBZhuT%j4DC;TN-Npu2miOyP7d6w_N2xWa_w3Mjdg> zz~!?>s<+VQW5sFn{BPUfb3|I0oQ%0Q1H~cmdQ#PG>2_5qxnI19Fm->u{ zXx&6-hQNml-+CR`u50MaJ5nMMwi&a$N1N% z44xz5C>7`B{BlydGTh-<)|)kCV0z2{^wr$l%#p;t4Wl9cCMVEF7IbIL*$m`-oVNf$bu7dQZmubRfDU*ZXO_>u&Vt=8ObWRnrbkvJ0^8xuFsMW9GY#QSnR#D(yuMKp!>Z-V! z!zg|$ry4dQ>pYIlndb5yQ{#^ zX;0!bkz61E(Owk{DkqKJ+{;Y2)_D1CD>WLqGyy`$5SdvfJX2FO8()-4@lnuAb-i&N zAu%O1pt-6(a~1|?(MW6JI!JYB%||)+1tc>yVP`R&3L<5lQRKfVzx@wy6T!%l^vp1R zK1wy~@TPe`dwPp6W=xAwGcw}Y5Z7V>0(^Gf zI>R`?=Z!4ESF;U+`aa0?I%`8TFl!PYeRig8ftL!@YCY0Gk|mf~7WaG>u{qek;a&VE zpdHV$FeFE~M#_1-PhT6cs8g9ta#-44L-A(#3mQZ|ZvnM6W`Fp@Rd#4SREk}j4svrV z24Ft6D8t@S=Db-&Jz=+7IKB@w=^{)AOTK1_4;11&a`=vNG8%5Ro~_g+2`-CND(dOe z{FeqZWMW0&k3b>9m`;mA(^X^tDAz)evtPfQVRlS~CMx_#aijn7b+?_-d!+*H$awT* z?!yA3-bp9IdVeIbiX^oDAgu>1e13@ds%!~(vvVy~hZgO4l9MNQ`RL>(mwUuC&L(W< zaLD9}_yx))TTSzJuKpgP+6XEYB?+FJ>Oi?X@eIzNs2weNVla*k3|V?nM;ue zl-NDtUKzX04L%_)46B0k|EN++HxFc-sa;3yIjV(wOn;q@6w}(UzLtB#SdLwhVrM6SKyJIX`yE95nn8E0C!RHVE3MsnM1iuVS)wRa zuOoq)qu?3=Z8U%9f!((d%U1rR+61z?>wcJ)9SbX&Q+$gJalxx#BQrY{cPQ_vl~J$w z%v0JU2Y(k`r1;vkeE8m$C_Q`(nQ`f(;b=!#KwN_9SWYz5KycssAQs9$<}i7%f?q5Y z#!7$pR#=I{F$TU$$^K<}(W*VW3S#;wEJ#smU3ht%Yu7Noa;ium;{LZTqGlMGQ4R1< zY7t^-MLd83zCo`=?_n44^;~;6&&lustTFqFf`2ebWxrecN9LP(yduh_I}*H6a4MzB zxjw)aB^JMmG~~DCUm!QgV@T=*05hBT&~}{6B@GbAkVOP8Y!?f^RjovDcZ|{ z13A3_+I4EgI;GIs;MA}IlyRH!P~YlD{ebaNq98x|H~~mR3%l;y$B*D@FYa~F&^t~7 z0e`aEPodM5NlJv6&fIy$+s{3IsLX>|OK_X1`N46BstbO>F~risd`@dbxc5QEs7`!4 znCtMh{1AyyR`49Qn9rde&DT3IrRv%H`Boj@o5R-P+;E(psa?;g9r)`r2`f@z;7V*z%J4;wMUA8H_S(_`On9%Kyla6n%*6hNI5>J4Igy-@Tsr^%qrLFFV9!puq=2G-+0B7aO2z$PcvnVYE;^o-|sF4?`5DZboNxPO#9sU+kj#xyC! z0FC333x*uz-ACa&YKVNW(z*=vKFC=^JU)Nf_nhMgzle#e{{FITy=NB3md+31{GO(( zOV1;EiBjqK=eLd6Nwyd-N-)XfzSy{W6&vwcdx!mP?C_2T0;sC!74P>pO@l&Q?BJ&; zdVTeMq`-co4n2yzCV#id;i-^ab2kr{QKJ#+qNHkc9J6mmj?{Z9h3Qk}ZAvJ+%sCv~ zX!#*8_d-~%^G{vN+(-Wf8|o926X6h3>ej@di3Uz{4I}2TKZu&%bTV#2(&EfiTO0J= zib$V=;W9iKM|AcQ?^wAWBl&lwo;%|BDwIU=YL8&#yZP9Nk!PFbnq)hjhI~}Q6%5I40&qKvAi9v#?{+^4uzs({6+8rTQsINkWZ6xk$jser zk=XPqkIzF=EPv3C72@GK!5+e2_&=IyD{`fIqK#3nLnX=;L=>IjDxu-ozHDo*>o3H< ztpeH_e)56cfbFA&ea&hp1(R}qTIVAVo7TIigALIp!j1`+jA9v=so88)PacXhD|#EQ zKNi<*SNu#rdJ@cwUf6N02a6se=Qr}fc&3`x|u;4c()Om|7;%U=?6WXfL zC{WIGTw@bDwVq&ocPWNUL(EaGVK91OQ-Ah17L{H$>mLMdKKjt~k38qmf3RDV*h;)uMApJWHyJa~lkmdeMR zyGVNCNkNy{6oaf|J3CgCO5*~OJ)e<-!B3>QuQV-j{=CQl1S^Eytrj{g56uy^dE!Wt)xmQHrA4hh|YJ!T?gc6D$nb{gN$H$g) z$XZOF&&&V;J6{|<%;LHZd)$G!1Wu-T4Fdy|T%D3C6-+L6^0dab8`4pQjiNm2)9~#K z4Uc(tsf7Bn;kCZ@4=&2`c(0odNO$!K$A7`pjU#vc!O|6jh@>|u_Gj%=4wbh|7yky9 zgswpEhukd3mh5M33j4?{xRoU>-g{+&#x!n==zrkJ{U70&a7GlX0E#jWnSv8{(`P2( zJGEy$3kAof*J&-=bpX`MshpE3cEtaabW{-z&OWY3i~R6eJ7o9>Xut`uD91lOtAE%# z87rKL*)hc8eOc4bjC4SgMPWXzXP2;x*q9)HI~9ca70t81#OXd>mS%g6fXC z6`sOwo3zVp7ygJt%<;`V)QxOIJ%3C`)R{Nyd9NXhagT}471qz~`!Ipk%zl)LAj6r5 z>)Mdlh)l#Rr_|6hp(Q^30I@Zi@!h`^4>SAvOJu-g-9U;Zppj-u1?$!lrnuB)6)PaK zLR@p&M3tvDO<1rn1>I&DdscKwe_2q5axS|z6^sSx6!n;kaGZ4I%zljdmVa_Lzb^r8 z2!&NV z5-Ja;$6Fr1;z}zz)@`zgT&Yp4FVi|#-*jT^#icB)Fx_W8{(yYJKMX*|{p_msUpLM_ zoz6@pbZl}?kc$`uMSrJ$nrg~K3M!W}8!u*v)2&Kh7Cb?7{@Vj+`g83?WNnyj!y(Rh zo|SG?CkH2E5*sKv;C>(w<~JF&$#W!KJm=KahRJ_=XDcTwJ1vp`taM*7EtfX+*pobbkbB2S{a7y2c*qx@mj* zXiUR-6#LcJFN*a6f5CIhfnOugeslI_Ug}IuZL6>8-Vb&cG8JkE(XAG) zjv=J8cGrSlo`6$Vv=KUkrdfmzVL73gI`q-jHSn&!t~>?0VE+6>Blub*%cQp+T$d*8!5!*K;*3i%ODG&paIcvZ2$;y<+HBIvId z6hgJ)>wno5$R5T1MH2rEdz$}a5|#!V>!L{u{Qrw4J8F3q|J4TNBp=s%)J>ZMrS6)4 zC<*`Nbfe1jlNQ_)45(QZJZ<-V`t#oT5`@sB+}yfSsMgHG#jc&TXN%=Che)048C|S( zxb~@eFGUx}UfdZZZ&$P2>C8&@8+XgvJaVs>LVuaqK4~_K)SI^CdWo(F15PeDCU_GT zzwx;XyjY+-6^9A!`Q8g?!V05BqCwqF7VC^37a1UDV$p_`)1Ar3zIL!K#&(?rUuK6l zX!$m;D;awnVBPsov^!gw>b=>3KqG&ZKaXJSBk#DNn+S-26=J^$(@RVrgRpI z=YQJjG{dZxZ#vS_Mu9$7n$av zh`q#^J&RC_cx=}r8f(@{{YL?UvoMNL-hWJ%#8pPHU`FBqfoUWNLQy>^=D(!X*cz;| z=#TYqDwT3NIQy*yOlcf=kALhMjdMbuXeoE=8AkeEzEcwUOg}|>J)VT83rs7E!&}=6 z(-G#Qs0wJzjPEhCqxiYbXj`W3L-7C+I2=O^EC8eJh>*#&Y|@#KZI)9R{0RV=|LSPnin`{gf| z0uVe&gx^IXQgo&t*DR(ZGd+e0I+T#?E4X2GhnTaJ&#_?K7vDN-06-|dE`MxtZX6?7 zvA<%_p-nLo-4wh{BLChL@JpGK8tJ&DmHrkS*QtqBVWEm4qQ{==9j~@7Aj01|m)%s@ zf8jE3`+1s?xf%YI41aFr=;GEm8RL1(%^hD}XPuz#$D3q^^%3zbp-*S*=O7a&tOvuN zA7Y6EHu3V}Dmj>$`;nl$<;^NB_wahlVs~t`{_Bd1k}77Zgu(A$*Ke zI4``=;BF!u{)Pk(@eVmC9+l0v+xF6Pk7ksHa6{ixf%8?9`>HZeQ%gFXyDI0gwyA&6 z9Dy^yOj+_JKHA{~NnA#!Zgp26l+U)*%bhAbpSiHY2gO{^NWd@H=6_;XV~QuPZW*665Gqr=A=sWL6(n0Mqljw7-hm zIPw~fjwO6fhGezGXv3qExpFlGt(%}}L*jH+T$+sXJ-?Cg3RsN#n|Zn`Kk`Uwl`!!y zE&+BG)Qhb5bXz?SkyW8?}~8ilVabmY45FdVjFWBKUw8ir;p-%Oc0+ zR7pt)+m@kX#Mas{-grOaC)3ZGP}qT)aS26+r+=oazE03d+_A0G_|c()vY9UH>|G2k zscCbQ8mOTP=j?0Xn)fC6l!kgx%_N>%?+WVSy)95$>(SD|jp7%JLcsD>nT38MZKn@T zpx;2N9-c8gB7dUePl;0D{z-`k%+IebaJ%!Jm-j;Lmimed8G?2UgL>}TqRtL& zG|+1E*bd@pP4`m0d@m+~Z4{Vo*78Aaf%S@?cqi|}!GEFxM2!fOosUHousYN43JdpE zt0p@sgR}KqPpQdIh$GxQVW$yTaUbQpx}Bw}l}lIzzt!;_XV@v!g;E~!!;aUDV~u&}@Whvt3tSEC+o%Dx z1c56}B!5@Ydi*hYf)2&6kJ%d3hUL$%O^p2=HN1vt{k#HBr#kGvpEVhtJ>D(?99euK zaZ8d3V4;Zwe^{Xz9n$=RD`&D8xybea<@|MxU~R1^s9bFYN$-mK6lfU~lEDl!&daQ} z#Xb1;Vr^WZVO&BL)*tMk5D}T zX>q%{6bho6VXXKVW>k;{WZVq}3lP4hOmLyj{H?FT9_gwF;88OxfY3BP2ER-kP3##H@j8IOHpa zntv&`DIy(e`Bbu28NUSSr|mCQrapKr);5IAW$-sYDiLc4Fw0w6VUPeBh}i5nSf8Yi zunmZ%rGaRG^hFPup=2;WT)|)n1}QoRT>FruS25%4%klefJe)bt-?QvRuC2sGAlmQj zn5RVV^wUr4)QxLadcvBICd~+tnzn&ZZhxibevP)kaY9Y~9J2;^QBi z(F8>oJ)^ki!I!L3Kzgj<+}{b{KI&U(Sd#^O=Ga38QV^jZD^0@YB=QPfR37>@t$$~L z(|e}T57nxaEuDRRg!&^H#MvJg9r(TVnv=D&(xIT<{+|o|- zvAFEqP?{H7#CIvO|7o^xtxT!B>`7iJU5t#Vx6eTU$w$wieLZd1FrvQ_mqilD*l<$ zTtZVuh3a~oMAUNVA8>+cfS1@l=Y%&A2I{i$P^{xw;W4x^1OKhQWCrk7wl}Z|f!h?A zG2p7(1DKZSod5y%Toize4*;f@ R&$O|`XZr#G00004Sz2BzFbMzv delta 11472 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

dmn#%|Xea%ggtDd_~@wrMvUM4mprKjsZS&?jyjD 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 + 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), From 5f21e24a3d8c98e726d822d2961978d729e66e43 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 17 Feb 2024 18:52:33 +0100 Subject: [PATCH 13/17] fix: assure that `..` patterns don't end up being `.` that matches nothing ...if the current-dir is one level in. Now such `.` is special and means to match everything. --- gix-pathspec/src/pattern.rs | 1 + gix-pathspec/tests/normalize/mod.rs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) 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/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(()) From 1e853961b0254893e277a0e14ee89099bac097f3 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 17 Feb 2024 16:06:57 +0100 Subject: [PATCH 14/17] fix!: leave more control to the user when creating pathspecs --- gix/src/dirwalk.rs | 10 +++++ gix/src/repository/dirwalk.rs | 70 ++++++++++++++++++++++------------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/gix/src/dirwalk.rs b/gix/src/dirwalk.rs index 852de17fec3..2f17e06d63d 100644 --- a/gix/src/dirwalk.rs +++ b/gix/src/dirwalk.rs @@ -17,6 +17,7 @@ pub struct Options { emit_empty_directories: bool, classify_untracked_bare_repositories: bool, emit_collapsed: Option, + pub(crate) use_prefix: bool, } /// Construction @@ -34,6 +35,7 @@ impl Options { emit_empty_directories: false, classify_untracked_bare_repositories: false, emit_collapsed: None, + use_prefix: false, } } } @@ -57,6 +59,14 @@ impl From for gix_dir::walk::Options { } impl Options { + /// If `true`, default `false`, pathspecs and the directory walk itself will be setup to use the [prefix](crate::Repository::prefix). + /// + /// 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 use_prefix(mut self, toggle: bool) -> Self { + self.use_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 { diff --git a/gix/src/repository/dirwalk.rs b/gix/src/repository/dirwalk.rs index 325e89ec624..4895c0ec36f 100644 --- a/gix/src/repository/dirwalk.rs +++ b/gix/src/repository/dirwalk.rs @@ -1,5 +1,5 @@ use crate::bstr::BStr; -use crate::{config, dirwalk, Repository}; +use crate::{config, dirwalk, AttributeStack, Pathspec, Repository}; use std::path::PathBuf; /// The error returned by [dirwalk()](Repository::dirwalk()). @@ -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,41 @@ impl Repository { patterns: impl IntoIterator>, options: dirwalk::Options, delegate: &mut dyn gix_dir::walk::Delegate, - ) -> Result<(gix_dir::walk::Outcome, PathBuf), Error> { + ) -> 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(); - gix_trace::debug!(patterns = ?pathspec.patterns().map(|p| p.path()).collect::>()); + let mut excludes = self.excludes( + index, + None, + crate::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, + )?; + let mut pathspec = self.pathspec( + 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 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( + 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,13 +97,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: None, + explicit_traversal_root: (!options.use_prefix).then_some(workdir), }, options.into(), delegate, - ) - .map_err(Into::into) + )?; + + Ok(Outcome { + dirwalk: outcome, + traversal_root, + excludes, + pathspec, + }) } } From 9ba8bcab07f4b3d0048b5a125b632bd70fe788d9 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 18 Feb 2024 10:39:37 +0100 Subject: [PATCH 15/17] feat: add `relativize_with_prefix()`. With it, a path 'a' with prefix 'b' will be '../a'. --- gix-path/src/convert.rs | 46 +++++++++++++++++++++++++++++++++++ gix-path/tests/convert/mod.rs | 36 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) 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"); + } +} From 0b1b44fa79a60ed40a9da154f7487408e6436941 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 18 Feb 2024 14:23:03 +0100 Subject: [PATCH 16/17] feat!: empty pathspecs with prefix now are optionally matching the prefix. Otherwise it's not possible to have the 'no pattern matches everything' case which is important in conjunction with prefixes and the requirement to still see everything outside of the prefix. --- gix/src/dirwalk.rs | 11 ++++++----- gix/src/pathspec.rs | 10 +++++++++- gix/src/repository/dirwalk.rs | 3 ++- gix/src/repository/pathspec.rs | 5 ++++- gix/src/worktree/mod.rs | 3 +++ gix/tests/repository/pathspec.rs | 2 ++ 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/gix/src/dirwalk.rs b/gix/src/dirwalk.rs index 2f17e06d63d..59357662c63 100644 --- a/gix/src/dirwalk.rs +++ b/gix/src/dirwalk.rs @@ -17,7 +17,7 @@ pub struct Options { emit_empty_directories: bool, classify_untracked_bare_repositories: bool, emit_collapsed: Option, - pub(crate) use_prefix: bool, + pub(crate) empty_patterns_match_prefix: bool, } /// Construction @@ -35,7 +35,7 @@ impl Options { emit_empty_directories: false, classify_untracked_bare_repositories: false, emit_collapsed: None, - use_prefix: false, + empty_patterns_match_prefix: false, } } } @@ -59,12 +59,13 @@ impl From for gix_dir::walk::Options { } impl Options { - /// If `true`, default `false`, pathspecs and the directory walk itself will be setup to use the [prefix](crate::Repository::prefix). + /// 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 use_prefix(mut self, toggle: bool) -> Self { - self.use_prefix = toggle; + 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, diff --git a/gix/src/pathspec.rs b/gix/src/pathspec.rs index 86041aa01f8..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(), diff --git a/gix/src/repository/dirwalk.rs b/gix/src/repository/dirwalk.rs index 4895c0ec36f..2495b544925 100644 --- a/gix/src/repository/dirwalk.rs +++ b/gix/src/repository/dirwalk.rs @@ -64,6 +64,7 @@ impl Repository { 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, @@ -99,7 +100,7 @@ impl Repository { }, excludes: Some(&mut excludes.inner), objects: &self.objects, - explicit_traversal_root: (!options.use_prefix).then_some(workdir), + explicit_traversal_root: (!options.empty_patterns_match_prefix).then_some(workdir), }, options.into(), delegate, 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/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", From 417f99fc7b144245bca0fa12e3839c3b4542936c Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 18 Feb 2024 10:34:29 +0100 Subject: [PATCH 17/17] adapt to changes in `gix` --- gitoxide-core/src/query/engine/command.rs | 1 + .../src/repository/attributes/query.rs | 30 +++++++++-------- gitoxide-core/src/repository/clean.rs | 13 +++----- gitoxide-core/src/repository/exclude.rs | 32 ++++++++++--------- gitoxide-core/src/repository/index/entries.rs | 1 + gitoxide-core/src/repository/status.rs | 21 ++++++++---- 6 files changed, 55 insertions(+), 43 deletions(-) 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 be7c6b0ff66..f570129987d 100644 --- a/gitoxide-core/src/repository/clean.rs +++ b/gitoxide-core/src/repository/clean.rs @@ -74,14 +74,10 @@ 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()?.unwrap_or(Path::new("")); - let prefix_len = if prefix.as_os_str().is_empty() { - 0 - } else { - prefix.to_str().map_or(0, |s| s.len() + 1 /* slash */) - }; let entries = collect.inner.into_entries_by_path(); let mut entries_to_clean = 0; @@ -173,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; @@ -181,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) => { @@ -221,7 +218,7 @@ pub(crate) mod function { 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 { 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 18208b5a8e3..ee36b1b1f3f 100644 --- a/gitoxide-core/src/repository/status.rs +++ b/gitoxide-core/src/repository/status.rs @@ -6,6 +6,7 @@ use gix::{ Progress, }; use gix_status::index_as_worktree::{traits::FastEq, Change, Conflict, EntryStatus}; +use std::path::{Path, PathBuf}; use crate::OutputFormat; @@ -54,15 +55,19 @@ pub fn show( gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, )? .detach(); - let pathspec = gix::Pathspec::new(&repo, pathspecs.iter().map(|p| p.as_bstr()), true, || Ok(stack.clone()))?; + 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()?, }; + 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())))? @@ -87,15 +92,16 @@ pub fn show( let repo = repo.clone().into_sync(); let index = &index; let collect = &mut collect; - move || { + move || -> anyhow::Result<_> { let repo = repo.to_thread_local(); - repo.dirwalk( + let outcome = repo.dirwalk( index, pathspecs, repo.dirwalk_options()? .emit_untracked(gix::dir::walk::EmissionMode::CollapseDirectory), collect, - ) + )?; + Ok(outcome.dirwalk) } })?; @@ -125,7 +131,7 @@ pub fn show( printer.out, "{status: >3} {rela_path}", status = "?", - rela_path = entry.rela_path + rela_path = gix::path::relativize_with_prefix(&gix::path::from_bstr(entry.rela_path), prefix).display() )?; } @@ -175,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 { @@ -226,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()) } }