From d0c5a0eb6801814df2ff21d850d8980e639fabaf Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 25 Jan 2024 20:44:53 +0100 Subject: [PATCH 01/15] fix: re-export crates whose types are used in the API. That way it's easier to use them without adding own dependnecies. --- gix-worktree/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gix-worktree/src/lib.rs b/gix-worktree/src/lib.rs index 2646495661..240285a912 100644 --- a/gix-worktree/src/lib.rs +++ b/gix-worktree/src/lib.rs @@ -10,8 +10,15 @@ #![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg, doc_auto_cfg))] #![deny(missing_docs, rust_2018_idioms, unsafe_code)] use bstr::BString; +/// Provides types needed for using [`stack::Platform::matching_attributes()`]. +#[cfg(feature = "attributes")] +pub use gix_attributes as attributes; /// A way to access the [`Case`](glob::pattern::Case) enum which used throughout this API. pub use gix_glob as glob; +/// Provides types needed for using [`stack::Platform::excluded_kind()`]. +pub use gix_ignore as ignore; +/// Provides types needed for using [`Stack::at_path()`] and [`Stack::at_entry()`]. +pub use gix_object as object; /// A cache for efficiently executing operations on directories and files which are encountered in sorted order. /// That way, these operations can be re-used for subsequent invocations in the same directory. From e409e8d35075721f57dedca5e65ecd110bd2a5ec Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sat, 27 Jan 2024 10:40:50 +0100 Subject: [PATCH 02/15] feat: add `Search::can_match_relative_path()`. This way it's possible to match partial input against a pathspec to see if this root would have a chance to actually match. --- gix-pathspec/src/pattern.rs | 6 + gix-pathspec/src/search/matching.rs | 65 ++++++++++- gix-pathspec/tests/search/mod.rs | 165 +++++++++++++++++++++++++++- 3 files changed, 233 insertions(+), 3 deletions(-) diff --git a/gix-pathspec/src/pattern.rs b/gix-pathspec/src/pattern.rs index 09fd058f1e..20194f3d11 100644 --- a/gix-pathspec/src/pattern.rs +++ b/gix-pathspec/src/pattern.rs @@ -142,6 +142,12 @@ impl Pattern { self.signature.contains(MagicSignature::EXCLUDE) } + /// Returns `true` is this pattern is supposed to always match, as it's either empty or designated `nil`. + /// Note that technically the pattern might still be excluded. + pub fn always_matches(&self) -> bool { + self.is_nil() || self.path.is_empty() + } + /// Translate ourselves to a long display format, that when parsed back will yield the same pattern. /// /// Note that the diff --git a/gix-pathspec/src/search/matching.rs b/gix-pathspec/src/search/matching.rs index c7c8f2cbb3..18bba276a9 100644 --- a/gix-pathspec/src/search/matching.rs +++ b/gix-pathspec/src/search/matching.rs @@ -8,7 +8,7 @@ use crate::{ impl Search { /// Return the first [`Match`] of `relative_path`, or `None`. - /// `is_dir` is `true` if `relative_path` is a directory. + /// `is_dir` is `true` if `relative_path` is a directory, or assumed `false` if `None`. /// `attributes` is called as `attributes(relative_path, case, is_dir, outcome) -> has_match` to obtain for attributes for `relative_path`, if /// the underlying pathspec defined an attribute filter, to be stored in `outcome`, returning true if there was a match. /// All attributes of the pathspec have to be present in the defined value for the pathspec to match. @@ -52,7 +52,7 @@ impl Search { } let case = if ignore_case { Case::Fold } else { Case::Sensitive }; - let mut is_match = mapping.value.pattern.is_nil() || mapping.value.pattern.path.is_empty(); + let mut is_match = mapping.value.pattern.always_matches(); if !is_match { is_match = if mapping.pattern.first_wildcard_pos.is_none() { match_verbatim(mapping, relative_path, is_dir, case) @@ -117,6 +117,67 @@ impl Search { res } } + + /// As opposed to [`Self::pattern_matching_relative_path()`], this method will return `true` for a possibly partial `relative_path` + /// if this pathspec *could* match by looking at the shortest shared prefix only. + /// + /// This is useful if `relative_path` is a directory leading up to the item that is going to be matched in full later. + /// Note that it should not end with `/` to indicate it's a directory, rather, use `is_dir` to indicate this. + /// `is_dir` is `true` if `relative_path` is a directory, or assumed `false` if `None`. + /// Returns `false` if this pathspec has no chance of ever matching `relative_path`. + pub fn can_match_relative_path(&self, relative_path: &BStr, is_dir: Option) -> bool { + if self.patterns.is_empty() { + return true; + } + let common_prefix_len = self.common_prefix_len.min(relative_path.len()); + if relative_path.get(..common_prefix_len).map_or(true, |rela_path_prefix| { + rela_path_prefix != self.common_prefix()[..common_prefix_len] + }) { + return false; + } + for mapping in &self.patterns { + let pattern = &mapping.value.pattern; + if mapping.pattern.first_wildcard_pos == Some(0) && !pattern.is_excluded() { + return true; + } + let max_usable_pattern_len = mapping.pattern.first_wildcard_pos.unwrap_or_else(|| pattern.path.len()); + let common_len = max_usable_pattern_len.min(relative_path.len()); + + let pattern_path = pattern.path[..common_len].as_bstr(); + let longest_possible_relative_path = &relative_path[..common_len]; + let ignore_case = pattern.signature.contains(MagicSignature::ICASE); + let mut is_match = pattern.always_matches(); + if !is_match && common_len != 0 { + is_match = if ignore_case { + pattern_path.eq_ignore_ascii_case(longest_possible_relative_path) + } else { + pattern_path == longest_possible_relative_path + }; + + if is_match { + is_match = if common_len < max_usable_pattern_len { + pattern.path.get(common_len) == Some(&b'/') + } else if relative_path.len() > max_usable_pattern_len { + relative_path.get(common_len) == Some(&b'/') + } else { + is_match + }; + if let Some(is_dir) = is_dir.filter(|_| pattern.signature.contains(MagicSignature::MUST_BE_DIR)) { + is_match = if is_dir { + matches!(pattern.path.get(common_len), None | Some(&b'/')) + } else { + relative_path.get(common_len) == Some(&b'/') + } + } + } + } + if is_match { + return !pattern.is_excluded(); + } + } + + self.all_patterns_are_excluded + } } fn match_verbatim( diff --git a/gix-pathspec/tests/search/mod.rs b/gix-pathspec/tests/search/mod.rs index 5266f3d0cb..9945f88e0d 100644 --- a/gix-pathspec/tests/search/mod.rs +++ b/gix-pathspec/tests/search/mod.rs @@ -15,6 +15,139 @@ fn no_pathspecs_match_everything() -> crate::Result { }) .expect("matches"); assert_eq!(m.pattern.prefix_directory(), "", "there is no prefix as none was given"); + assert_eq!( + m.sequence_number, 0, + "this is actually a fake pattern, as we have to match even though there isn't anything" + ); + + assert!(search.can_match_relative_path("anything".into(), None)); + + Ok(()) +} + +#[test] +fn simplified_search_respects_must_be_dir() -> crate::Result { + let mut search = gix_pathspec::Search::from_specs(pathspecs(&["a/be/"]), None, Path::new(""))?; + search + .pattern_matching_relative_path("a/be/file".into(), Some(false), &mut |_, _, _, _| { + unreachable!("must not be called") + }) + .expect("matches as this is a prefix match"); + assert!( + !search.can_match_relative_path("any".into(), Some(false)), + "not our directory: a, and must be dir" + ); + assert!( + !search.can_match_relative_path("any".into(), Some(true)), + "not our directory: a" + ); + assert!( + !search.can_match_relative_path("any".into(), None), + "not our directory: a, and must be dir, still completely out of scope" + ); + assert!( + !search.can_match_relative_path("a/bei".into(), None), + "not our directory: a/be" + ); + assert!(!search.can_match_relative_path("a".into(), Some(false)), "must be dir"); + assert!(search.can_match_relative_path("a".into(), Some(true))); + assert!( + search.can_match_relative_path("a".into(), None), + "now dir or not doesn't matter" + ); + assert!(search.can_match_relative_path("a/be".into(), Some(true))); + assert!( + search.can_match_relative_path("a/be".into(), None), + "dir doesn't matter anymore" + ); + assert!( + !search.can_match_relative_path("a/be".into(), Some(false)), + "files can't match as prefix" + ); + assert!( + search.can_match_relative_path("a/be/file".into(), Some(false)), + "files can match if they are part of the suffix" + ); + + assert!( + !search.can_match_relative_path("a/b".into(), Some(false)), + "can't match a/be" + ); + assert!( + !search.can_match_relative_path("a/b".into(), None), + "still can't match a/be" + ); + assert!( + search + .pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called")) + .is_none(), + "no match if it's not the whole pattern that matches" + ); + assert!( + !search.can_match_relative_path("a/b".into(), Some(true)), + "can't match a/be, which must be directory" + ); + + Ok(()) +} + +#[test] +fn simplified_search_respects_ignore_case() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&[":(icase)foo/**/bar"]), None, Path::new(""))?; + assert!(search.can_match_relative_path("Foo".into(), None)); + assert!(search.can_match_relative_path("foo".into(), Some(true))); + assert!(search.can_match_relative_path("FOO/".into(), Some(true))); + + Ok(()) +} + +#[test] +fn simplified_search_respects_all_excluded() -> crate::Result { + let search = gix_pathspec::Search::from_specs( + pathspecs(&[":(exclude)a/file", ":(exclude)b/file"]), + None, + Path::new(""), + )?; + assert!(!search.can_match_relative_path("b".into(), None)); + assert!(!search.can_match_relative_path("a".into(), None)); + assert!(search.can_match_relative_path("c".into(), None)); + assert!(search.can_match_relative_path("c/".into(), None)); + + Ok(()) +} + +#[test] +fn simplified_search_wildcards() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&["**/a*"]), None, Path::new(""))?; + assert!( + search.can_match_relative_path("a".into(), None), + "it can't determine it, so assume match" + ); + assert!(search.can_match_relative_path("a/a".into(), Some(false))); + assert!(search.can_match_relative_path("a/a.o".into(), Some(false))); + assert!( + search.can_match_relative_path("b-unrelated".into(), None), + "this is also assumed to be a match, prefer false-positives over false-negatives" + ); + Ok(()) +} + +#[test] +fn simplified_search_handles_nil() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&[":"]), None, Path::new(""))?; + assert!(search.can_match_relative_path("a".into(), None), "everything matches"); + assert!(search.can_match_relative_path("a".into(), Some(false))); + assert!(search.can_match_relative_path("a".into(), Some(true))); + assert!(search.can_match_relative_path("a/b".into(), Some(true))); + + let search = gix_pathspec::Search::from_specs(pathspecs(&[":(exclude)"]), None, Path::new(""))?; + assert!( + !search.can_match_relative_path("a".into(), None), + "everything does not match" + ); + assert!(!search.can_match_relative_path("a".into(), Some(false))); + assert!(!search.can_match_relative_path("a".into(), Some(true))); + assert!(!search.can_match_relative_path("a/b".into(), Some(true))); Ok(()) } @@ -28,6 +161,15 @@ fn init_with_exclude() -> crate::Result { "re-orded so that excluded are first" ); assert_eq!(search.common_prefix(), "tests"); + assert!( + search.can_match_relative_path("tests".into(), Some(true)), + "prefix matches" + ); + assert!( + !search.can_match_relative_path("test".into(), Some(true)), + "prefix can not be shorter" + ); + assert!(!search.can_match_relative_path("outside-of-tests".into(), None)); Ok(()) } @@ -47,6 +189,7 @@ fn no_pathspecs_respect_prefix() -> crate::Result { .is_none(), "not the right prefix" ); + assert!(!search.can_match_relative_path("hello".into(), None)); let m = search .pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called")) .expect("match"); @@ -55,12 +198,16 @@ fn no_pathspecs_respect_prefix() -> crate::Result { "a", "the prefix directory matched verbatim" ); + assert!(search.can_match_relative_path("a/".into(), Some(true))); + assert!(search.can_match_relative_path("a".into(), Some(true))); + assert!(!search.can_match_relative_path("a".into(), Some(false))); + assert!(search.can_match_relative_path("a".into(), None), "simple prefix search"); Ok(()) } #[test] -fn prefixes_are_always_case_insensitive() -> crate::Result { +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)?; @@ -108,6 +255,22 @@ fn prefixes_are_always_case_insensitive() -> crate::Result { .collect(); assert_eq!(actual, expected, "{spec} {prefix}"); } + + let search = gix_pathspec::Search::from_specs( + gix_pathspec::parse(":(icase)bar".as_bytes(), Default::default()), + Some(Path::new("FOO")), + Path::new(""), + )?; + assert!( + !search.can_match_relative_path("foo".into(), Some(true)), + "icase does not apply to the prefix" + ); + assert!(search.can_match_relative_path("FOO".into(), Some(true))); + assert!( + !search.can_match_relative_path("FOO/ba".into(), Some(true)), + "a full match is needed" + ); + assert!(search.can_match_relative_path("FOO/bar".into(), Some(true))); Ok(()) } From 021d4c185525e9b289462bf23811fcaa283d8bef Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 4 Feb 2024 13:26:17 +0100 Subject: [PATCH 03/15] feat!: add `search::MatchKind`, which is available for any `search::Match`. With it the caller can learn how or why the pathspec matched, which allows to make decisions based on it that are relevant to the user interface. --- gix-pathspec/src/search/matching.rs | 20 +++++--- gix-pathspec/src/search/mod.rs | 16 +++++++ gix-pathspec/tests/search/mod.rs | 73 +++++++++++++++++++++++------ 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/gix-pathspec/src/search/matching.rs b/gix-pathspec/src/search/matching.rs index 18bba276a9..1be8b34af8 100644 --- a/gix-pathspec/src/search/matching.rs +++ b/gix-pathspec/src/search/matching.rs @@ -1,6 +1,8 @@ use bstr::{BStr, BString, ByteSlice}; use gix_glob::pattern::Case; +use crate::search::MatchKind; +use crate::search::MatchKind::*; use crate::{ search::{Match, Spec}, MagicSignature, Pattern, Search, SearchMode, @@ -53,9 +55,10 @@ impl Search { let case = if ignore_case { Case::Fold } else { Case::Sensitive }; let mut is_match = mapping.value.pattern.always_matches(); + let mut how = Always; if !is_match { is_match = if mapping.pattern.first_wildcard_pos.is_none() { - match_verbatim(mapping, relative_path, is_dir, case) + match_verbatim(mapping, relative_path, is_dir, case, &mut how) } else { let wildmatch_mode = match mapping.value.pattern.search_mode { SearchMode::ShellGlob => Some(gix_glob::wildmatch::Mode::empty()), @@ -72,12 +75,13 @@ impl Search { wildmatch_mode, ); if !is_match { - match_verbatim(mapping, relative_path, is_dir, case) + match_verbatim(mapping, relative_path, is_dir, case, &mut how) } else { + how = mapping.pattern.first_wildcard_pos.map_or(Verbatim, |_| WildcardMatch); true } } - None => match_verbatim(mapping, relative_path, is_dir, case), + None => match_verbatim(mapping, relative_path, is_dir, case, &mut how), } } } @@ -97,6 +101,7 @@ impl Search { is_match.then_some(Match { pattern: &mapping.value.pattern, sequence_number: mapping.sequence_number, + kind: how, }) }); @@ -112,6 +117,7 @@ impl Search { Some(Match { pattern: &MATCH_ALL_STAND_IN, sequence_number: patterns_len, + kind: Always, }) } else { res @@ -185,16 +191,18 @@ fn match_verbatim( relative_path: &BStr, is_dir: bool, case: Case, + how: &mut MatchKind, ) -> bool { let pattern_len = mapping.value.pattern.path.len(); let mut relative_path_ends_with_slash_at_pattern_len = false; - let match_is_allowed = relative_path.get(pattern_len).map_or_else( - || relative_path.len() == pattern_len, + let (match_is_allowed, probably_how) = relative_path.get(pattern_len).map_or_else( + || (relative_path.len() == pattern_len, Verbatim), |b| { relative_path_ends_with_slash_at_pattern_len = *b == b'/'; - relative_path_ends_with_slash_at_pattern_len + (relative_path_ends_with_slash_at_pattern_len, Prefix) }, ); + *how = probably_how; let pattern_requirement_is_met = !mapping.pattern.mode.contains(gix_glob::pattern::Mode::MUST_BE_DIR) || (relative_path_ends_with_slash_at_pattern_len || is_dir); diff --git a/gix-pathspec/src/search/mod.rs b/gix-pathspec/src/search/mod.rs index 3544daec52..761bd94393 100644 --- a/gix-pathspec/src/search/mod.rs +++ b/gix-pathspec/src/search/mod.rs @@ -9,6 +9,22 @@ pub struct Match<'a> { pub pattern: &'a Pattern, /// The number of the sequence the matching pathspec was in, or the line of pathspec file it was read from if [Search::source] is not `None`. pub sequence_number: usize, + /// How the pattern matched. + pub kind: MatchKind, +} + +/// Describe how a pathspec pattern matched. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, Ord, PartialOrd)] +pub enum MatchKind { + /// The match happened because there wasn't any pattern, which matches all, or because there was a nil pattern or one with an empty path. + /// Thus this is not a match by merit. + Always, + /// The first part of a pathspec matches, like `dir/` that matches `dir/a`. + Prefix, + /// The whole pathspec matched and used a wildcard match, like `a/*` matching `a/file`. + WildcardMatch, + /// The entire pathspec matched, letter by letter, e.g. `a/file` matching `a/file`. + Verbatim, } mod init; diff --git a/gix-pathspec/tests/search/mod.rs b/gix-pathspec/tests/search/mod.rs index 9945f88e0d..f4a33aafea 100644 --- a/gix-pathspec/tests/search/mod.rs +++ b/gix-pathspec/tests/search/mod.rs @@ -1,3 +1,5 @@ +use bstr::BStr; +use gix_pathspec::search::MatchKind::*; use std::path::Path; #[test] @@ -10,29 +12,67 @@ fn no_pathspecs_match_everything() -> crate::Result { let mut search = gix_pathspec::Search::from_specs([], None, Path::new(""))?; assert_eq!(search.patterns().count(), 0, "nothing artificial is added"); let m = search - .pattern_matching_relative_path("hello".into(), None, &mut |_, _, _, _| { - unreachable!("must not be called") - }) + .pattern_matching_relative_path("hello".into(), None, &mut no_attrs) .expect("matches"); assert_eq!(m.pattern.prefix_directory(), "", "there is no prefix as none was given"); + assert_eq!(m.kind, Always, "no pathspec always matches"); assert_eq!( m.sequence_number, 0, "this is actually a fake pattern, as we have to match even though there isn't anything" ); - assert!(search.can_match_relative_path("anything".into(), None)); + Ok(()) +} +#[test] +fn starts_with() -> crate::Result { + let mut search = gix_pathspec::Search::from_specs(pathspecs(&["a/*"]), None, Path::new(""))?; + assert!( + search + .pattern_matching_relative_path("a".into(), Some(false), &mut no_attrs) + .is_none(), + "this can only match if it's a directory" + ); + assert!( + search + .pattern_matching_relative_path("a".into(), Some(true), &mut no_attrs) + .is_none(), + "can't match as the '*' part is missing in value" + ); + assert!( + search.can_match_relative_path("a".into(), Some(true)), + "prefix-matches work though" + ); + assert!( + search.can_match_relative_path("a".into(), Some(false)), + "but not if it's a file" + ); + assert!( + search.can_match_relative_path("a".into(), None), + "if unspecified, we match for good measure" + ); + assert_eq!( + search + .pattern_matching_relative_path("a/file".into(), None, &mut no_attrs) + .expect("matches") + .kind, + WildcardMatch, + "a wildmatch is always performed here, even though it looks like a prefix" + ); Ok(()) } #[test] fn simplified_search_respects_must_be_dir() -> crate::Result { let mut search = gix_pathspec::Search::from_specs(pathspecs(&["a/be/"]), None, Path::new(""))?; - search - .pattern_matching_relative_path("a/be/file".into(), Some(false), &mut |_, _, _, _| { - unreachable!("must not be called") - }) - .expect("matches as this is a prefix match"); + assert_eq!( + search + .pattern_matching_relative_path("a/be/file".into(), Some(false), &mut no_attrs) + .expect("matches as this is a prefix match") + .kind, + Prefix, + "a verbatim part of the spec matches" + ); assert!( !search.can_match_relative_path("any".into(), Some(false)), "not our directory: a, and must be dir" @@ -79,7 +119,7 @@ fn simplified_search_respects_must_be_dir() -> crate::Result { ); assert!( search - .pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called")) + .pattern_matching_relative_path("a/b".into(), None, &mut no_attrs) .is_none(), "no match if it's not the whole pattern that matches" ); @@ -183,21 +223,20 @@ fn no_pathspecs_respect_prefix() -> crate::Result { ); assert!( search - .pattern_matching_relative_path("hello".into(), None, &mut |_, _, _, _| unreachable!( - "must not be called" - )) + .pattern_matching_relative_path("hello".into(), None, &mut no_attrs) .is_none(), "not the right prefix" ); assert!(!search.can_match_relative_path("hello".into(), None)); let m = search - .pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called")) + .pattern_matching_relative_path("a/b".into(), None, &mut no_attrs) .expect("match"); assert_eq!( m.pattern.prefix_directory(), "a", "the prefix directory matched verbatim" ); + assert_eq!(m.kind, Prefix, "the common path also works like a prefix"); assert!(search.can_match_relative_path("a/".into(), Some(true))); assert!(search.can_match_relative_path("a".into(), Some(true))); assert!(!search.can_match_relative_path("a".into(), Some(false))); @@ -249,7 +288,7 @@ fn prefixes_are_always_case_sensitive() -> crate::Result { .iter() .filter(|relative_path| { search - .pattern_matching_relative_path(relative_path.as_str().into(), Some(false), &mut |_, _, _, _| false) + .pattern_matching_relative_path(relative_path.as_str().into(), Some(false), &mut no_attrs) .is_some() }) .collect(); @@ -442,3 +481,7 @@ mod baseline { Ok((root, items, expected)) } } + +fn no_attrs(_: &BStr, _: gix_glob::pattern::Case, _: bool, _: &mut gix_attributes::search::Outcome) -> bool { + unreachable!("must not be called") +} From f520a516e712566cba47ff983cd0f2f3faf6286a Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 5 Feb 2024 08:32:37 +0100 Subject: [PATCH 04/15] feat: Add `Search::directory_matches_prefix()` to see if the prefix of a pathspec matches. That way it's possible to see if some paths can never match. --- gix-pathspec/src/search/matching.rs | 57 +++++++++++++- gix-pathspec/tests/search/mod.rs | 113 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/gix-pathspec/src/search/matching.rs b/gix-pathspec/src/search/matching.rs index 1be8b34af8..a3bc82a96e 100644 --- a/gix-pathspec/src/search/matching.rs +++ b/gix-pathspec/src/search/matching.rs @@ -149,11 +149,11 @@ impl Search { let max_usable_pattern_len = mapping.pattern.first_wildcard_pos.unwrap_or_else(|| pattern.path.len()); let common_len = max_usable_pattern_len.min(relative_path.len()); - let pattern_path = pattern.path[..common_len].as_bstr(); - let longest_possible_relative_path = &relative_path[..common_len]; let ignore_case = pattern.signature.contains(MagicSignature::ICASE); let mut is_match = pattern.always_matches(); if !is_match && common_len != 0 { + let pattern_path = pattern.path[..common_len].as_bstr(); + let longest_possible_relative_path = &relative_path[..common_len]; is_match = if ignore_case { pattern_path.eq_ignore_ascii_case(longest_possible_relative_path) } else { @@ -184,6 +184,59 @@ impl Search { self.all_patterns_are_excluded } + + /// Returns `true` if `relative_path` matches the prefix of this pathspec. + /// + /// For example, the relative path `d` matches `d/`, `d*/`, `d/` and `d/*`, but not `d/d/*` or `dir`. + /// When `leading` is `true`, then `d` matches `d/d` as well. Thus `relative_path` must may be + /// partially included in `pathspec`, otherwise it has to be fully included. + pub fn directory_matches_prefix(&self, relative_path: &BStr, leading: bool) -> bool { + if self.patterns.is_empty() { + return true; + } + let common_prefix_len = self.common_prefix_len.min(relative_path.len()); + if relative_path.get(..common_prefix_len).map_or(true, |rela_path_prefix| { + rela_path_prefix != self.common_prefix()[..common_prefix_len] + }) { + return false; + } + for mapping in &self.patterns { + let pattern = &mapping.value.pattern; + if mapping.pattern.first_wildcard_pos.is_some() && pattern.is_excluded() { + return true; + } + let mut rightmost_idx = mapping.pattern.first_wildcard_pos.map_or_else( + || pattern.path.len(), + |idx| pattern.path[..idx].rfind_byte(b'/').unwrap_or(idx), + ); + let ignore_case = pattern.signature.contains(MagicSignature::ICASE); + let mut is_match = pattern.always_matches(); + if !is_match { + let plen = relative_path.len(); + if leading && rightmost_idx > plen { + if let Some(idx) = pattern.path[..plen] + .rfind_byte(b'/') + .or_else(|| pattern.path[plen..].find_byte(b'/').map(|idx| idx + plen)) + { + rightmost_idx = idx; + } + } + if let Some(relative_path) = relative_path.get(..rightmost_idx) { + let pattern_path = pattern.path[..rightmost_idx].as_bstr(); + is_match = if ignore_case { + pattern_path.eq_ignore_ascii_case(relative_path) + } else { + pattern_path == relative_path + }; + } + } + if is_match { + return !pattern.is_excluded(); + } + } + + self.all_patterns_are_excluded + } } fn match_verbatim( diff --git a/gix-pathspec/tests/search/mod.rs b/gix-pathspec/tests/search/mod.rs index f4a33aafea..accf03334f 100644 --- a/gix-pathspec/tests/search/mod.rs +++ b/gix-pathspec/tests/search/mod.rs @@ -7,6 +7,116 @@ fn directories() -> crate::Result { baseline::run("directory", true, baseline::directories) } +#[test] +fn directory_matches_prefix() -> crate::Result { + for spec in ["dir", "dir/", "di*", "dir/*", "dir/*.o"] { + for specs in [&[spec] as &[_], &[spec, "other"]] { + let search = gix_pathspec::Search::from_specs(pathspecs(specs), None, Path::new(""))?; + assert!( + search.directory_matches_prefix("dir".into(), false), + "{spec}: must match" + ); + assert!( + !search.directory_matches_prefix("d".into(), false), + "{spec}: must not match" + ); + } + } + + for spec in ["dir/d", "dir/d/", "dir/*/*", "dir/d/*.o"] { + for specs in [&[spec] as &[_], &[spec, "other"]] { + let search = gix_pathspec::Search::from_specs(pathspecs(specs), None, Path::new(""))?; + assert!( + search.directory_matches_prefix("dir/d".into(), false), + "{spec}: must match" + ); + assert!( + search.directory_matches_prefix("dir/d".into(), true), + "{spec}: must match" + ); + for leading in [false, true] { + assert!( + !search.directory_matches_prefix("d".into(), leading), + "{spec}: must not match" + ); + assert!( + !search.directory_matches_prefix("di".into(), leading), + "{spec}: must not match" + ); + } + } + } + Ok(()) +} + +#[test] +fn directory_matches_prefix_starting_wildcards_always_match() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&["*ir"]), None, Path::new(""))?; + assert!(search.directory_matches_prefix("dir".into(), false)); + assert!(search.directory_matches_prefix("d".into(), false)); + Ok(()) +} + +#[test] +fn directory_matches_prefix_leading() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&["d/d/generated/b"]), None, Path::new(""))?; + assert!(!search.directory_matches_prefix("di".into(), false)); + assert!(!search.directory_matches_prefix("di".into(), true)); + assert!(search.directory_matches_prefix("d".into(), true)); + assert!(!search.directory_matches_prefix("d".into(), false)); + assert!(search.directory_matches_prefix("d/d".into(), true)); + assert!(!search.directory_matches_prefix("d/d".into(), false)); + assert!(search.directory_matches_prefix("d/d/generated".into(), true)); + assert!(!search.directory_matches_prefix("d/d/generated".into(), false)); + assert!(!search.directory_matches_prefix("d/d/generatedfoo".into(), false)); + assert!(!search.directory_matches_prefix("d/d/generatedfoo".into(), true)); + + let search = gix_pathspec::Search::from_specs(pathspecs(&[":(icase)d/d/GENERATED/b"]), None, Path::new(""))?; + assert!( + search.directory_matches_prefix("d/d/generated".into(), true), + "icase is respected as well" + ); + assert!(!search.directory_matches_prefix("d/d/generated".into(), false)); + Ok(()) +} + +#[test] +fn directory_matches_prefix_negative_wildcard() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&[":!*generated*"]), None, Path::new(""))?; + assert!( + search.directory_matches_prefix("di".into(), false), + "it's always considered matching, we can't really tell anyway" + ); + assert!(search.directory_matches_prefix("di".into(), true)); + assert!(search.directory_matches_prefix("d".into(), true)); + assert!(search.directory_matches_prefix("d".into(), false)); + assert!(search.directory_matches_prefix("d/d".into(), true)); + assert!(search.directory_matches_prefix("d/d".into(), false)); + assert!(search.directory_matches_prefix("d/d/generated".into(), true)); + assert!(search.directory_matches_prefix("d/d/generated".into(), false)); + assert!(search.directory_matches_prefix("d/d/generatedfoo".into(), false)); + assert!(search.directory_matches_prefix("d/d/generatedfoo".into(), true)); + + let search = gix_pathspec::Search::from_specs(pathspecs(&[":(exclude,icase)*GENERATED*"]), None, Path::new(""))?; + assert!(search.directory_matches_prefix("d/d/generated".into(), true)); + assert!(search.directory_matches_prefix("d/d/generated".into(), false)); + Ok(()) +} + +#[test] +fn directory_matches_prefix_all_excluded() -> crate::Result { + for spec in ["!dir", "!dir/", "!d*", "!di*", "!dir/*", "!dir/*.o", "!*ir"] { + for specs in [&[spec] as &[_], &[spec, "other"]] { + let search = gix_pathspec::Search::from_specs(pathspecs(specs), None, Path::new(""))?; + assert!( + !search.directory_matches_prefix("dir".into(), false), + "{spec}: must not match, it's excluded" + ); + } + } + Ok(()) +} + #[test] fn no_pathspecs_match_everything() -> crate::Result { let mut search = gix_pathspec::Search::from_specs([], None, Path::new(""))?; @@ -21,6 +131,7 @@ fn no_pathspecs_match_everything() -> crate::Result { "this is actually a fake pattern, as we have to match even though there isn't anything" ); assert!(search.can_match_relative_path("anything".into(), None)); + assert!(search.directory_matches_prefix("anything".into(), false)); Ok(()) } @@ -51,6 +162,8 @@ fn starts_with() -> crate::Result { search.can_match_relative_path("a".into(), None), "if unspecified, we match for good measure" ); + assert!(search.directory_matches_prefix("a".into(), false)); + assert!(!search.directory_matches_prefix("ab".into(), false)); assert_eq!( search .pattern_matching_relative_path("a/file".into(), None, &mut no_attrs) From ea2e5bbc4657aba5d2209d9f22fa076148d5df6f Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Fri, 9 Feb 2024 08:23:50 +0100 Subject: [PATCH 05/15] fix: Return `ExactSizeIterator` where applicable. That way, the caller can obtain the amount of patterns more easily. --- gix-pathspec/src/search/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gix-pathspec/src/search/mod.rs b/gix-pathspec/src/search/mod.rs index 761bd94393..9e98183f8a 100644 --- a/gix-pathspec/src/search/mod.rs +++ b/gix-pathspec/src/search/mod.rs @@ -39,7 +39,7 @@ impl Match<'_> { /// Access impl Search { /// Return an iterator over the patterns that participate in the search. - pub fn patterns(&self) -> impl Iterator + '_ { + pub fn patterns(&self) -> impl ExactSizeIterator + '_ { self.patterns.iter().map(|m| &m.value.pattern) } From 9e3acde9c98537c7c8ee58f632ce21fcca5b066d Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 28 Jan 2024 20:35:54 +0100 Subject: [PATCH 06/15] feat: add `str::precompose_bstr()` for convenience --- Cargo.lock | 1 + gix-pathspec/src/search/matching.rs | 7 +++++-- gix-pathspec/tests/search/mod.rs | 25 +++++++++++++++++++++++++ gix-utils/Cargo.toml | 4 ++++ gix-utils/src/str.rs | 23 +++++++++++++++++++---- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1f656cb14..04ee9eb377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2755,6 +2755,7 @@ dependencies = [ name = "gix-utils" version = "0.1.9" dependencies = [ + "bstr", "fastrand 2.0.1", "unicode-normalization", ] diff --git a/gix-pathspec/src/search/matching.rs b/gix-pathspec/src/search/matching.rs index a3bc82a96e..37a14a34b1 100644 --- a/gix-pathspec/src/search/matching.rs +++ b/gix-pathspec/src/search/matching.rs @@ -129,7 +129,8 @@ impl Search { /// /// This is useful if `relative_path` is a directory leading up to the item that is going to be matched in full later. /// Note that it should not end with `/` to indicate it's a directory, rather, use `is_dir` to indicate this. - /// `is_dir` is `true` if `relative_path` is a directory, or assumed `false` if `None`. + /// `is_dir` is `true` if `relative_path` is a directory. If `None`, the fact that a pathspec might demand a directory match + /// is ignored. /// Returns `false` if this pathspec has no chance of ever matching `relative_path`. pub fn can_match_relative_path(&self, relative_path: &BStr, is_dir: Option) -> bool { if self.patterns.is_empty() { @@ -163,7 +164,9 @@ impl Search { if is_match { is_match = if common_len < max_usable_pattern_len { pattern.path.get(common_len) == Some(&b'/') - } else if relative_path.len() > max_usable_pattern_len { + } else if relative_path.len() > max_usable_pattern_len + && mapping.pattern.first_wildcard_pos.is_none() + { relative_path.get(common_len) == Some(&b'/') } else { is_match diff --git a/gix-pathspec/tests/search/mod.rs b/gix-pathspec/tests/search/mod.rs index accf03334f..6dd826aa34 100644 --- a/gix-pathspec/tests/search/mod.rs +++ b/gix-pathspec/tests/search/mod.rs @@ -285,6 +285,31 @@ fn simplified_search_wildcards() -> crate::Result { Ok(()) } +#[test] +fn simplified_search_wildcards_simple() -> crate::Result { + let search = gix_pathspec::Search::from_specs(pathspecs(&["dir/*"]), None, Path::new(""))?; + for is_dir in [None, Some(false), Some(true)] { + assert!( + !search.can_match_relative_path("a".into(), is_dir), + "definitely out of bound" + ); + assert!( + !search.can_match_relative_path("di".into(), is_dir), + "prefix is not enough" + ); + assert!( + search.can_match_relative_path("dir".into(), is_dir), + "directories can match" + ); + assert!( + search.can_match_relative_path("dir/file".into(), is_dir), + "substrings can also match" + ); + } + + Ok(()) +} + #[test] fn simplified_search_handles_nil() -> crate::Result { let search = gix_pathspec::Search::from_specs(pathspecs(&[":"]), None, Path::new(""))?; diff --git a/gix-utils/Cargo.toml b/gix-utils/Cargo.toml index e5364e99c6..a197b4677a 100644 --- a/gix-utils/Cargo.toml +++ b/gix-utils/Cargo.toml @@ -12,7 +12,11 @@ include = ["src/**/*", "LICENSE-*"] [lib] doctest = false +[features] +bstr = ["dep:bstr"] + [dependencies] fastrand = "2.0.0" +bstr = { version = "1.5.0", default-features = false, features = ["std"], optional = true } unicode-normalization = { version = "0.1.19", default-features = false } diff --git a/gix-utils/src/str.rs b/gix-utils/src/str.rs index 84c77f5c38..a8adb141e6 100644 --- a/gix-utils/src/str.rs +++ b/gix-utils/src/str.rs @@ -42,11 +42,26 @@ pub fn precompose_path(path: Cow<'_, Path>) -> Cow<'_, Path> { /// Return the precomposed version of `name`, or `name` itself if it contained illformed unicode, /// or if the unicode version didn't contains decomposed unicode. /// Otherwise, similar to [`precompose()`] -pub fn precompose_os_string(path: Cow<'_, OsStr>) -> Cow<'_, OsStr> { - match path.to_str() { - None => path, +pub fn precompose_os_string(name: Cow<'_, OsStr>) -> Cow<'_, OsStr> { + match name.to_str() { + None => name, Some(maybe_decomposed) => match precompose(maybe_decomposed.into()) { - Cow::Borrowed(_) => path, + Cow::Borrowed(_) => name, + Cow::Owned(precomposed) => Cow::Owned(precomposed.into()), + }, + } +} + +/// Return the precomposed version of `s`, or `s` itself if it contained illformed unicode, +/// or if the unicode version didn't contains decomposed unicode. +/// Otherwise, similar to [`precompose()`] +#[cfg(feature = "bstr")] +pub fn precompose_bstr(s: Cow<'_, bstr::BStr>) -> Cow<'_, bstr::BStr> { + use bstr::ByteSlice; + match s.to_str().ok() { + None => s, + Some(maybe_decomposed) => match precompose(maybe_decomposed.into()) { + Cow::Borrowed(_) => s, Cow::Owned(precomposed) => Cow::Owned(precomposed.into()), }, } From c8ccbe58cbafd70936b4143db01687e5bf9f7cf4 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 29 Jan 2024 16:07:05 +0100 Subject: [PATCH 07/15] feat: add `try_os_str_into_bstr()`, with `Cow` as input. --- gix-path/src/convert.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gix-path/src/convert.rs b/gix-path/src/convert.rs index fc7f5c41a0..261faa6d0c 100644 --- a/gix-path/src/convert.rs +++ b/gix-path/src/convert.rs @@ -36,6 +36,14 @@ pub fn os_string_into_bstring(path: OsString) -> Result { } } +/// Like [`into_bstr()`], but takes `Cow` as input for a lossless, but fallible, conversion. +pub fn try_os_str_into_bstr(path: Cow<'_, OsStr>) -> Result, Utf8Error> { + match path { + Cow::Borrowed(path) => os_str_into_bstr(path).map(Cow::Borrowed), + Cow::Owned(path) => os_string_into_bstring(path).map(Cow::Owned), + } +} + /// Convert the given path either into its raw bytes on unix or its UTF8 encoded counterpart on windows. /// /// On windows, if the source Path contains ill-formed, lone surrogates, the UTF-8 conversion will fail From cbda06d7673c3461b82f5999295d3b00a7c273ef Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Tue, 6 Feb 2024 20:19:06 +0100 Subject: [PATCH 08/15] fix!: refactor ignore-case functionality to actually work. The new implementation does the same as Git, and initializes an alternative lookup location. All previous implementations with `_icase` suffix were removed without replacement. --- Cargo.lock | 6 +- crate-status.md | 4 +- gix-index/Cargo.toml | 2 + gix-index/src/access/mod.rs | 371 +++++++----------- gix-index/src/decode/mod.rs | 10 +- gix-index/src/lib.rs | 40 +- .../ignore-case-realistic.git-index | Bin 0 -> 230807 bytes gix-index/tests/index/access.rs | 275 +++++-------- 8 files changed, 275 insertions(+), 433 deletions(-) create mode 100644 gix-index/tests/fixtures/loose_index/ignore-case-realistic.git-index diff --git a/Cargo.lock b/Cargo.lock index 04ee9eb377..8b0df36e87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "ahash" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01" dependencies = [ "cfg-if", "getrandom", @@ -1988,6 +1988,7 @@ dependencies = [ "btoi", "document-features", "filetime", + "fnv", "gix-bitmap 0.2.10", "gix-features 0.38.0", "gix-fs 0.10.0", @@ -1995,6 +1996,7 @@ dependencies = [ "gix-lock 13.1.0", "gix-object 0.41.0", "gix-traverse 0.37.0", + "hashbrown 0.14.3", "itoa", "libc", "memmap2 0.9.3", diff --git a/crate-status.md b/crate-status.md index 51e5db6c72..d8b5d3b478 100644 --- a/crate-status.md +++ b/crate-status.md @@ -658,7 +658,9 @@ The git staging area. * `stat` update * [ ] optional threaded `stat` based on thread_cost (aka preload) * [x] handling of `.gitignore` and system file exclude configuration -* [ ] handle potential races +* [x] lookups that ignore the case + * [ ] multi-threaded lookup table generation with the same algorithm as the one used by Git + * [ ] expand sparse folders (don't know how this relates to traversals right now) * maintain extensions when altering the cache * [ ] TREE for speeding up tree generation * [ ] REUC resolving undo diff --git a/gix-index/Cargo.toml b/gix-index/Cargo.toml index bba8fb5423..472094d4e2 100644 --- a/gix-index/Cargo.toml +++ b/gix-index/Cargo.toml @@ -28,6 +28,8 @@ gix-traverse = { version = "^0.37.0", path = "../gix-traverse" } gix-lock = { version = "^13.0.0", path = "../gix-lock" } gix-fs = { version = "^0.10.0", path = "../gix-fs" } +hashbrown = "0.14.3" +fnv = "1.0.7" thiserror = "1.0.32" memmap2 = "0.9.0" filetime = "0.2.15" diff --git a/gix-index/src/access/mod.rs b/gix-index/src/access/mod.rs index b9ea4b4171..4bd01579ba 100644 --- a/gix-index/src/access/mod.rs +++ b/gix-index/src/access/mod.rs @@ -3,7 +3,7 @@ use std::{cmp::Ordering, ops::Range}; use bstr::{BStr, ByteSlice, ByteVec}; use filetime::FileTime; -use crate::{entry, extension, DirectoryKind, Entry, PathStorage, PathStorageRef, State, Version}; +use crate::{entry, extension, AccelerateLookup, Entry, PathStorage, PathStorageRef, State, Version}; // TODO: integrate this somehow, somewhere, depending on later usage. #[allow(dead_code)] @@ -84,38 +84,6 @@ impl State { self.entry_index_by_idx_and_stage(path, idx, stage, stage_cmp) } - /// Find the entry index in [`entries()`][State::entries()] matching the given repository-relative - /// `path` and `stage`, or `None`. - /// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed. - /// - /// Note that if there are ambiguities, like `x` and `X` being present in the index, any of these will be returned, - /// deterministically. - /// - /// Use the index for accessing multiple stages if they exists, but at least the single matching entry. - pub fn entry_index_by_path_and_stage_icase( - &self, - path: &BStr, - stage: entry::Stage, - ignore_case: bool, - ) -> Option { - if ignore_case { - let mut stage_cmp = Ordering::Equal; - let idx = self - .entries - .binary_search_by(|e| { - let res = icase_cmp(e.path(self), path); - if res.is_eq() { - stage_cmp = e.stage().cmp(&stage); - } - res - }) - .ok()?; - self.entry_index_by_idx_and_stage_icase(path, idx, stage, stage_cmp) - } else { - self.entry_index_by_path_and_stage(path, stage) - } - } - /// Walk as far in `direction` as possible, with [`Ordering::Greater`] towards higher stages, and [`Ordering::Less`] /// towards lower stages, and return the lowest or highest seen stage. /// Return `None` if there is no greater or smaller stage. @@ -140,30 +108,6 @@ impl State { } } - /// Walk as far in `direction` as possible, with [`Ordering::Greater`] towards higher stages, and [`Ordering::Less`] - /// towards lower stages, and return the lowest or highest seen stage. - /// Return `None` if there is no greater or smaller stage. - fn walk_entry_stages_icase(&self, path: &BStr, base: usize, direction: Ordering) -> Option { - match direction { - Ordering::Greater => self - .entries - .get(base + 1..)? - .iter() - .enumerate() - .take_while(|(_, e)| e.path(self).eq_ignore_ascii_case(path)) - .last() - .map(|(idx, _)| base + 1 + idx), - Ordering::Equal => Some(base), - Ordering::Less => self.entries[..base] - .iter() - .enumerate() - .rev() - .take_while(|(_, e)| e.path(self).eq_ignore_ascii_case(path)) - .last() - .map(|(idx, _)| idx), - } - } - fn entry_index_by_idx_and_stage( &self, path: &BStr, @@ -189,29 +133,126 @@ impl State { } } - fn entry_index_by_idx_and_stage_icase( - &self, + /// Return a data structure to help with case-insensitive lookups. + /// + /// It's required perform any case-insensitive lookup. + /// TODO: needs multi-threaded insertion, raw-table to have multiple locks depending on bucket. + pub fn prepare_icase_backing(&self) -> AccelerateLookup<'_> { + let _span = gix_features::trace::detail!("prepare_icase_backing", entries = self.entries.len()); + let mut out = AccelerateLookup::with_capacity(self.entries.len()); + for entry in &self.entries { + let entry_path = entry.path(self); + let hash = AccelerateLookup::icase_hash(entry_path); + out.icase_entries + .insert_unique(hash, entry, |e| AccelerateLookup::icase_hash(e.path(self))); + + let mut last_pos = entry_path.len(); + while let Some(slash_idx) = entry_path[..last_pos].rfind_byte(b'/') { + let dir = entry_path[..slash_idx].as_bstr(); + last_pos = slash_idx; + let dir_range = entry.path.start..(entry.path.start + dir.len()); + + let hash = AccelerateLookup::icase_hash(dir); + if out + .icase_dirs + .find(hash, |dir| { + dir.path(self) == self.path_backing[dir_range.clone()].as_bstr() + }) + .is_none() + { + out.icase_dirs.insert_unique( + hash, + crate::DirEntry { + entry, + dir_end: dir_range.end, + }, + |dir| AccelerateLookup::icase_hash(dir.path(self)), + ); + } else { + break; + } + } + } + gix_features::trace::detail!("stored directories", directories = out.icase_dirs.len()); + out + } + + /// Return the entry at `path` that is at the lowest available stage, using `lookup` for acceleration. + /// It must have been created from this instance, and was ideally kept up-to-date with it. + /// + /// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed. + pub fn entry_by_path_icase<'a>( + &'a self, path: &BStr, - idx: usize, - wanted_stage: entry::Stage, - stage_cmp: Ordering, - ) -> Option { - match stage_cmp { - Ordering::Greater => self.entries[..idx] - .iter() - .enumerate() - .rev() - .take_while(|(_, e)| e.path(self).eq_ignore_ascii_case(path)) - .find_map(|(idx, e)| (e.stage() == wanted_stage).then_some(idx)), - Ordering::Equal => Some(idx), - Ordering::Less => self - .entries - .get(idx + 1..)? - .iter() - .enumerate() - .take_while(|(_, e)| e.path(self).eq_ignore_ascii_case(path)) - .find_map(|(ofs, e)| (e.stage() == wanted_stage).then_some(idx + ofs + 1)), + ignore_case: bool, + lookup: &AccelerateLookup<'a>, + ) -> Option<&'a Entry> { + lookup + .icase_entries + .find(AccelerateLookup::icase_hash(path), |e| { + let entry_path = e.path(self); + if entry_path == path { + return true; + }; + if !ignore_case { + return false; + } + entry_path.eq_ignore_ascii_case(path) + }) + .copied() + } + + /// Return the entry (at any stage) that is inside of `directory`, or `None`, + /// using `lookup` for acceleration. + /// Note that submodules are not detected as directories and the user should + /// make another call to [`entry_by_path_icase()`](Self::entry_by_path_icase) to check for this + /// possibility. Doing so might also reveal a sparse directory. + /// + /// If `ignore_case` is set + pub fn entry_closest_to_directory_icase<'a>( + &'a self, + directory: &BStr, + ignore_case: bool, + lookup: &AccelerateLookup<'a>, + ) -> Option<&Entry> { + lookup + .icase_dirs + .find(AccelerateLookup::icase_hash(directory), |dir| { + let dir_path = dir.path(self); + if dir_path == directory { + return true; + }; + if !ignore_case { + return false; + } + dir_path.eq_ignore_ascii_case(directory) + }) + .map(|dir| dir.entry) + } + + /// Return the entry (at any stage) that is inside of `directory`, or `None`. + /// Note that submodules are not detected as directories and the user should + /// make another call to [`entry_by_path_icase()`](Self::entry_by_path_icase) to check for this + /// possibility. Doing so might also reveal a sparse directory. + /// + /// Note that this is a case-sensitive search. + pub fn entry_closest_to_directory(&self, directory: &BStr) -> Option<&Entry> { + let idx = self.entry_index_by_path(directory).err()?; + for entry in &self.entries[idx..] { + let path = entry.path(self); + if path.get(..directory.len())? != directory { + break; + } + let dir_char = path.get(directory.len())?; + if *dir_char > b'/' { + break; + } + if *dir_char < b'/' { + continue; + } + return Some(entry); } + None } /// Find the entry index in [`entries()[..upper_bound]`][State::entries()] matching the given repository-relative @@ -233,89 +274,13 @@ impl State { .ok() } - /// Like [`entry_index_by_path_and_stage()`](State::entry_index_by_path_and_stage_icase()), + /// Like [`entry_index_by_path_and_stage()`](State::entry_index_by_path_and_stage()), /// but returns the entry instead of the index. pub fn entry_by_path_and_stage(&self, path: &BStr, stage: entry::Stage) -> Option<&Entry> { self.entry_index_by_path_and_stage(path, stage) .map(|idx| &self.entries[idx]) } - /// Like [`entry_index_by_path_and_stage_icase()`](State::entry_index_by_path_and_stage_icase()), - /// but returns the entry instead of the index. - pub fn entry_by_path_and_stage_icase(&self, path: &BStr, stage: entry::Stage, ignore_case: bool) -> Option<&Entry> { - self.entry_index_by_path_and_stage_icase(path, stage, ignore_case) - .map(|idx| &self.entries[idx]) - } - - /// Return the kind of directory that `path` represents, or `None` if the path is not a directory, or not - /// tracked in this index in any other way. - /// - /// Note that we will not match `path`, like `a/b`, to a submodule or sparse directory at `a`, which means - /// that `path` should be grown one component at a time in order to find the relevant entries. - /// - /// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed. - /// - /// ### Deviation - /// - /// We allow conflicting entries to serve as indicator for an inferred directory, whereas `git` only looks - /// at stage 0. - pub fn directory_kind_by_path_icase(&self, path: &BStr, ignore_case: bool) -> Option { - if ignore_case { - for entry in self - .prefixed_entries_range_icase(path, ignore_case) - .map(|range| &self.entries[range])? - { - let entry_path = entry.path(self); - if !entry_path.get(..path.len())?.eq_ignore_ascii_case(path) { - // This can happen if the range starts with matches, then moves on to non-matches, - // to finally and in matches again. - // TODO(perf): start range from start to first mismatch, then continue from the end. - continue; - } - match entry_path.get(path.len()) { - Some(b'/') => { - return Some(if entry.mode.is_sparse() { - DirectoryKind::SparseDir - } else { - DirectoryKind::Inferred - }) - } - Some(_) => break, - None => { - if entry.mode.is_submodule() { - return Some(DirectoryKind::Submodule); - } - } - } - } - } else { - let (Ok(idx) | Err(idx)) = self.entries.binary_search_by(|e| e.path(self).cmp(path)); - - for entry in self.entries.get(idx..)? { - let entry_path = entry.path(self); - if entry_path.get(..path.len())? != path { - break; - } - match entry_path.get(path.len()) { - Some(b'/') => { - return Some(if entry.mode.is_sparse() { - DirectoryKind::SparseDir - } else { - DirectoryKind::Inferred - }) - } - Some(_) => break, - None => { - if entry.mode.is_submodule() { - return Some(DirectoryKind::Submodule); - } - } - } - } - } - None - } - /// Return the entry at `path` that is either at stage 0, or at stage 2 (ours) in case of a merge conflict. /// /// Using this method is more efficient in comparison to doing two searches, one for stage 0 and one for stage 2. @@ -339,35 +304,10 @@ impl State { Some(&self.entries[idx]) } - /// Return the entry at `path` that is either at stage 0, or at stage 2 (ours) in case of a merge conflict. - /// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed. - /// - /// Using this method is more efficient in comparison to doing two searches, one for stage 0 and one for stage 2. - /// - /// Note that if there are ambiguities, like `x` and `X` being present in the index, any of these will be returned, - /// deterministically. - pub fn entry_by_path_icase(&self, path: &BStr, ignore_case: bool) -> Option<&Entry> { - if ignore_case { - let mut stage_at_index = 0; - let idx = self - .entries - .binary_search_by(|e| { - let res = icase_cmp(e.path(self), path); - if res.is_eq() { - stage_at_index = e.stage(); - } - res - }) - .ok()?; - let idx = if stage_at_index == 0 || stage_at_index == 2 { - idx - } else { - self.entry_index_by_idx_and_stage_icase(path, idx, 2, stage_at_index.cmp(&2))? - }; - Some(&self.entries[idx]) - } else { - self.entry_by_path(path) - } + /// Return the index at `Ok(index)` where the entry matching `path` (in any stage) can be found, or return + /// `Err(index)` to indicate the insertion position at which an entry with `path` would fit in. + pub fn entry_index_by_path(&self, path: &BStr) -> Result { + self.entries.binary_search_by(|e| e.path(self).cmp(path)) } /// Return the slice of entries which all share the same `prefix`, or `None` if there isn't a single such entry. @@ -409,49 +349,6 @@ impl State { (low != high).then_some(low..high) } - /// Return the range of entries which all share the same `prefix`, or `None` if there isn't a single such entry. - /// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed. Otherwise - /// the search is case-sensitive. - /// - /// If `prefix` is empty, the range will include all entries. - pub fn prefixed_entries_range_icase(&self, prefix: &BStr, ignore_case: bool) -> Option> { - if ignore_case { - if prefix.is_empty() { - return Some(0..self.entries.len()); - } - let prefix_len = prefix.len(); - let mut low = self.entries.partition_point(|e| { - e.path(self).get(..prefix_len).map_or_else( - || icase_cmp(e.path(self), &prefix[..e.path.len()]).is_le(), - |p| icase_cmp(p, prefix).is_lt(), - ) - }); - let mut high = low - + self.entries[low..].partition_point(|e| { - e.path(self) - .get(..prefix_len) - .map_or(false, |p| icase_cmp(p, prefix).is_le()) - }); - - let low_entry = &self.entries.get(low)?; - if low_entry.stage() != 0 { - low = self - .walk_entry_stages_icase(low_entry.path(self), low, Ordering::Less) - .unwrap_or(low); - } - if let Some(high_entry) = self.entries.get(high) { - if high_entry.stage() != 0 { - high = self - .walk_entry_stages_icase(high_entry.path(self), high, Ordering::Less) - .unwrap_or(high); - } - } - (low != high).then_some(low..high) - } else { - self.prefixed_entries_range(prefix) - } - } - /// Return the entry at `idx` or _panic_ if the index is out of bounds. /// /// The `idx` is typically returned by [`entry_by_path_and_stage()`][State::entry_by_path_and_stage()]. @@ -491,10 +388,22 @@ impl State { } } -fn icase_cmp(a: &[u8], b: &[u8]) -> Ordering { - a.iter() - .map(u8::to_ascii_lowercase) - .cmp(b.iter().map(u8::to_ascii_lowercase)) +impl<'a> AccelerateLookup<'a> { + fn with_capacity(cap: usize) -> Self { + let ratio_of_entries_to_dirs_in_webkit = 20; // 400k entries and 20k dirs + Self { + icase_entries: hashbrown::HashTable::with_capacity(cap), + icase_dirs: hashbrown::HashTable::with_capacity(cap / ratio_of_entries_to_dirs_in_webkit), + } + } + fn icase_hash(data: &BStr) -> u64 { + use std::hash::Hasher; + let mut hasher = fnv::FnvHasher::default(); + for b in data.as_bytes() { + hasher.write_u8(b.to_ascii_lowercase()); + } + hasher.finish() + } } /// Mutation diff --git a/gix-index/src/decode/mod.rs b/gix-index/src/decode/mod.rs index 801b319f7e..d4a4d83328 100644 --- a/gix-index/src/decode/mod.rs +++ b/gix-index/src/decode/mod.rs @@ -35,7 +35,7 @@ use gix_features::parallel::InOrderIter; use crate::util::read_u32; /// Options to define how to decode an index state [from bytes][State::from_bytes()]. -#[derive(Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy)] pub struct Options { /// If Some(_), we are allowed to use more than one thread. If Some(N), use no more than N threads. If Some(0)|None, use as many threads /// as there are logical cores. @@ -59,13 +59,13 @@ impl State { data: &[u8], timestamp: FileTime, object_hash: gix_hash::Kind, - Options { + _options @ Options { thread_limit, min_extension_block_in_bytes_for_threading, expected_checksum, }: Options, ) -> Result<(Self, Option), Error> { - let _span = gix_features::trace::detail!("gix_index::State::from_bytes()"); + let _span = gix_features::trace::detail!("gix_index::State::from_bytes()", options = ?_options); let (version, num_entries, post_header_data) = header::decode(data, object_hash)?; let start_of_extensions = extension::end_of_index_entry::decode(data, object_hash); @@ -160,10 +160,10 @@ impl State { // 100GB/s on a single core. while let (Ok(lhs), Some(res)) = (acc.as_mut(), results.next()) { match res { - Ok(rhs) => { + Ok(mut rhs) => { lhs.is_sparse |= rhs.is_sparse; let ofs = lhs.path_backing.len(); - lhs.path_backing.extend(rhs.path_backing); + lhs.path_backing.append(&mut rhs.path_backing); lhs.entries.extend(rhs.entries.into_iter().map(|mut e| { e.path.start += ofs; e.path.end += ofs; diff --git a/gix-index/src/lib.rs b/gix-index/src/lib.rs index c642cafb55..9e993e3232 100644 --- a/gix-index/src/lib.rs +++ b/gix-index/src/lib.rs @@ -6,6 +6,7 @@ #![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg, doc_auto_cfg))] #![deny(unsafe_code, missing_docs, rust_2018_idioms)] +use bstr::{BStr, ByteSlice}; use std::{ops::Range, path::PathBuf}; use filetime::FileTime; @@ -47,22 +48,6 @@ pub enum Version { V4 = 4, } -/// A representation of a directory in the index. -/// -/// These are most of the time inferred, but may also be explicit entries. -#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)] -pub enum DirectoryKind { - /// The directory is implied as there is at least one tracked entry that lives within it. - Inferred, - /// The directory is present directly in the form of a sparse directory. - /// - /// These are available when cone-mode is active. - SparseDir, - /// The directory is present directly in the form of the commit of a repository that is - /// a submodule of the superproject (which this is the index of). - Submodule, -} - /// An entry in the index, identifying a non-tree item on disk. #[derive(Debug, Clone, Eq, PartialEq)] pub struct Entry { @@ -95,6 +80,29 @@ pub type PathStorage = Vec; /// The type to use and store paths to all entries, as reference pub type PathStorageRef = [u8]; +struct DirEntry<'a> { + /// The first entry in the directory + entry: &'a Entry, + /// One past the last byte of the directory in the path-backing + dir_end: usize, +} + +impl DirEntry<'_> { + fn path<'a>(&self, state: &'a State) -> &'a BStr { + let range = self.entry.path.start..self.dir_end; + state.path_backing[range].as_bstr() + } +} + +/// A backing store for accelerating lookups of entries in a case-sensitive and case-insensitive manner. +pub struct AccelerateLookup<'a> { + /// The entries themselves, hashed by their full icase path. + /// Icase-clashes are handled in order of occurrence and are all available for iteration. + icase_entries: hashbrown::HashTable<&'a Entry>, + /// Each hash in this table corresponds to a directory containing one or more entries. + icase_dirs: hashbrown::HashTable>, +} + /// An in-memory cache of a fully parsed git index file. /// /// As opposed to a snapshot, it's meant to be altered and eventually be written back to disk or converted into a tree. diff --git a/gix-index/tests/fixtures/loose_index/ignore-case-realistic.git-index b/gix-index/tests/fixtures/loose_index/ignore-case-realistic.git-index new file mode 100644 index 0000000000000000000000000000000000000000..438e5c70d2f238865e8178357d1b0a9e77873e4a GIT binary patch literal 230807 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 literal 0 HcmV?d00001 diff --git a/gix-index/tests/index/access.rs b/gix-index/tests/index/access.rs index e099f8ecc4..af76a45a29 100644 --- a/gix-index/tests/index/access.rs +++ b/gix-index/tests/index/access.rs @@ -1,141 +1,126 @@ use crate::index::Fixture; +use bstr::{BString, ByteSlice}; fn icase_fixture() -> gix_index::File { Fixture::Generated("v2_icase_name_clashes").open() } -mod directory_by_path { - use crate::index::Fixture; - use gix_index::DirectoryKind; - - #[test] - fn normal_entries_are_never_a_directory() { - for fixture in [ - Fixture::Generated("v2_deeper_tree.sh"), - Fixture::Generated("v2_more_files.sh"), - ] { - let file = fixture.open(); - for entry in file.entries() { - for ignore_case in [false, true] { - assert_eq!(file.directory_kind_by_path_icase(entry.path(&file), ignore_case), None); - } - } - } - } - - #[test] - fn inferred() { - let file = Fixture::Generated("v2_deeper_tree.sh").open(); - - let searches = ["d", "d/nested", "sub", "sub/a", "sub/b", "sub/c", "sub/c/d"]; - for search in searches { - assert_eq!( - file.directory_kind_by_path_icase(search.into(), false), - Some(DirectoryKind::Inferred), - "directories can be inferred if the index contains an entry in them" - ); - } - - for search in searches.into_iter().map(str::to_ascii_uppercase) { - assert_eq!( - file.directory_kind_by_path_icase(search.as_str().into(), true), - Some(DirectoryKind::Inferred), - "directories can be inferred if the index contains an entry in them, also in case-insensitive mode" - ); - assert_eq!( - file.directory_kind_by_path_icase(search.as_str().into(), false), - None, - "nothing can be found in case-sensitive mode" - ); - } - } - - #[test] - fn entries_themselves_are_returned_as_dir_only_if_sparse_or_commits() { - let file = Fixture::Generated("v2_all_file_kinds.sh").open(); - for (search, ignore_case) in [("sub", false), ("SuB", true)] { - assert_eq!( - file.directory_kind_by_path_icase(search.into(), ignore_case), - Some(DirectoryKind::Submodule), - "submodules can be found verbatim" - ); - } - - let file = Fixture::Generated("v3_sparse_index.sh").open(); - for sparse_dir in ["d", "c1/c3"] { - assert_eq!( - file.directory_kind_by_path_icase(sparse_dir.into(), false), - Some(DirectoryKind::SparseDir), - "sparse directories can be found verbatim" - ); - } - - for sparse_dir in ["D", "C1/c3", "c1/C3"] { - assert_eq!( - file.directory_kind_by_path_icase(sparse_dir.into(), true), - Some(DirectoryKind::SparseDir), - "sparse directories can be found verbatim" - ); - } +#[test] +fn entry_by_path() { + let file = icase_fixture(); + for entry in file.entries() { + let path = entry.path(&file); + assert_eq!(file.entry_by_path(path), Some(entry)); + assert_eq!(file.entry_by_path_and_stage(path, 0), Some(entry)); } +} - #[test] - fn icase_handling() { - let file = Fixture::Generated("v2_icase_name_clashes.sh").open(); +#[test] +fn dirwalk_api_and_icase_support() { + let file = Fixture::Loose("ignore-case-realistic").open(); + let icase = file.prepare_icase_backing(); + for entry in file.entries() { + let entry_path = entry.path(&file); + let a = file.entry_by_path_icase(entry_path, false, &icase); + let b = file.entry_by_path_icase(entry_path, true, &icase); + let c = file.entry_by_path_icase(entry_path.to_ascii_uppercase().as_bstr(), true, &icase); + assert_eq!( + a, + b, + "{entry_path}: an index without clashes produces exactly the same result, found {:?} and icase {:?}", + a.map(|e| e.path(&file)), + b.map(|e| e.path(&file)) + ); + assert_eq!( + a, + c, + "{entry_path}: lower-case lookups work as well, found {:?} and icase {:?}", + a.map(|e| e.path(&file)), + c.map(|e| e.path(&file)) + ); - for search in ["d", "D"] { - assert_eq!( - file.directory_kind_by_path_icase(search.into(), true), - Some(DirectoryKind::Inferred), - "There exists 'd' and 'D/file', and we manage to find the directory" + let mut last_pos = 0; + while let Some(slash_idx) = entry_path[last_pos..].find_byte(b'/') { + last_pos += slash_idx; + let dir = entry_path[..last_pos].as_bstr(); + last_pos += 1; + + let entry = file + .entry_closest_to_directory(dir) + .unwrap_or_else(|| panic!("didn't find {dir}")); + assert!( + entry.path(&file).starts_with(dir), + "entry must actually be inside of directory" ); - } - for ignore_case in [false, true] { - for search in ["d/x", "D/X", "D/B", "file", "FILE_X"] { - assert_eq!( - file.directory_kind_by_path_icase(search.into(), ignore_case), - None, - "even though `D` exists as directory, we are not able to find it, which makes sense as there is no sub-entry" - ); - } + let dir_upper: BString = dir.to_ascii_uppercase().into(); + let other_entry = file + .entry_closest_to_directory_icase(dir_upper.as_bstr(), true, &icase) + .unwrap_or_else(|| panic!("didn't find upper-cased {dir_upper}")); + assert_eq!(other_entry, entry, "the first entry is always the same, no matter what kind of search is conducted (as there are no clashes/ambiguities here)") } } } #[test] -fn entry_by_path() { +fn ignorecase_clashes_and_order() { let file = icase_fixture(); + let icase = file.prepare_icase_backing(); for entry in file.entries() { - let path = entry.path(&file); - assert_eq!(file.entry_by_path(path), Some(entry)); - assert_eq!(file.entry_by_path_and_stage(path, 0), Some(entry)); - } -} + let entry_path = entry.path(&file); + let a = file.entry_by_path_icase(entry_path, false, &icase); + assert_eq!( + a, + Some(entry), + "{entry_path}: in a case-sensitive search, we get exact matches, found {:?} ", + a.map(|e| e.path(&file)), + ); -#[test] -fn entry_by_path_icase() { - let file = icase_fixture(); + let mut last_pos = 0; + while let Some(slash_idx) = entry_path[last_pos..].find_byte(b'/') { + last_pos += slash_idx; + let dir = entry_path[..last_pos].as_bstr(); + last_pos += 1; + + let entry = file + .entry_closest_to_directory(dir) + .unwrap_or_else(|| panic!("didn't find {dir}")); + assert!( + entry.path(&file).starts_with(dir), + "entry must actually be inside of directory" + ); + } + } assert_eq!( - file.entry_by_path("D/b".into()), - None, - "the 'b' is uppercase in the index" + file.entry_by_path_icase("file_x".into(), true, &icase) + .map(|e| e.path(&file)) + .expect("in index"), + "FILE_X", + "it finds the entry that was inserted first" ); + assert_eq!( - file.entry_by_path_icase("D/b".into(), false), - None, - "ignore case off means it's just the same as the non-icase method" + file.entry_by_path_icase("x".into(), true, &icase) + .map(|e| e.path(&file)) + .expect("in index"), + "X", + "the file 'X' was inserted first, no way to see the symlink under 'x'" + ); + + assert!( + file.entry_closest_to_directory("d".into()).is_none(), + "this is a file, and this directory search isn't case-sensitive" ); + let entry = file.entry_closest_to_directory("D".into()); assert_eq!( - file.entry_by_path_icase("D/b".into(), true), - file.entry_by_path("D/B".into()), - "with case-folding, the index entry can be found" + entry.map(|e| e.path(&file)).expect("present"), + "D/B", + "this is a directory, indeed, we find the first file in it" ); - + let entry_icase = file.entry_closest_to_directory_icase("d".into(), true, &icase); assert_eq!( - file.entry_by_path_icase("file_x".into(), true), - file.entry_by_path("FILE_x".into()), - "case-folding can make matches ambiguous, and it's unclear what we get" + entry_icase, entry, + "case-insensitive searches don't confuse directories and files, so `d` finds `D`, the directory." ); } @@ -147,28 +132,6 @@ fn prefixed_entries_icase_with_name_clashes() { Some(7..9), "case sensitive search yields only two: file_x and file_X" ); - assert_eq!( - file.prefixed_entries_range_icase("file".into(), false), - Some(7..9), - "case-sensitivity can be turned off even for icase searches" - ); - assert_eq!( - file.prefixed_entries_range_icase("file".into(), true), - Some(3..9), - "case sensitive search yields all relevant items, but… it only assures the start and end of the range is correct \ - which is: 3: FILE_X, 4: FILE_x, …[not the right prefix]…, 7: file_X, 8: file_x" - ); - - assert_eq!( - file.prefixed_entries_range_icase("d/".into(), true), - Some(1..3), - "this emulates a directory search (but wouldn't catch git commits or sparse dirs): 1: D/B, 2: D/C" - ); - assert_eq!( - file.prefixed_entries_range_icase("d".into(), true), - Some(1..7), - "without slash one can get everything that matches: 1: D/B, 2: D/C, …inbetweens… 6: d" - ); } #[test] @@ -185,27 +148,6 @@ fn entry_by_path_and_stage() { } } -#[test] -fn entry_by_path_and_stage_icase() { - let file = icase_fixture(); - assert_eq!( - file.entry_by_path_and_stage_icase("D/b".into(), 0, true), - file.entry_by_path_and_stage("D/B".into(), 0), - "with case-folding, the index entry can be found" - ); - assert_eq!( - file.entry_by_path_and_stage_icase("D/b".into(), 0, false), - None, - "if case-folding is disabled, it is case-sensitive" - ); - - assert_eq!( - file.entry_by_path_and_stage_icase("file_x".into(), 0, true), - file.entry_by_path_and_stage("FILE_x".into(), 0), - "case-folding can make matches ambiguous, and it's unclear what we get" - ); -} - #[test] fn entry_by_path_with_conflicting_file() { let file = Fixture::Loose("conflicting-file").open(); @@ -311,21 +253,11 @@ fn sort_entries() { "d", &["d/a", "d/b", "d/c", "d/last/123", "d/last/34", "d/last/6"], ); - check_prefix_icase( - &file, - "D", - &["d/a", "d/b", "d/c", "d/last/123", "d/last/34", "d/last/6"], - ); check_prefix( &file, "d/", &["d/a", "d/b", "d/c", "d/last/123", "d/last/34", "d/last/6"], ); - check_prefix_icase( - &file, - "D/", - &["d/a", "d/b", "d/c", "d/last/123", "d/last/34", "d/last/6"], - ); check_prefix(&file, "d/last", &["d/last/123", "d/last/34", "d/last/6"]); check_prefix(&file, "d/last/", &["d/last/123", "d/last/34", "d/last/6"]); check_prefix(&file, "d/las", &["d/last/123", "d/last/34", "d/last/6"]); @@ -333,9 +265,7 @@ fn sort_entries() { check_prefix(&file, "d/last/34", &["d/last/34"]); check_prefix(&file, "d/last/6", &["d/last/6"]); check_prefix(&file, "x", &["x"]); - check_prefix_icase(&file, "X", &["x"]); check_prefix(&file, "a", &["a", "an initially incorrectly ordered entry"]); - check_prefix_icase(&file, "A", &["a", "an initially incorrectly ordered entry"]); } #[test] @@ -380,14 +310,3 @@ fn check_prefix(index: &gix_index::State, prefix: &str, expected: &[&str]) { "{prefix:?}" ); } - -fn check_prefix_icase(index: &gix_index::State, prefix: &str, expected: &[&str]) { - let range = index - .prefixed_entries_range_icase(prefix.into(), true) - .unwrap_or_else(|| panic!("{prefix:?} must match at least one entry")); - assert_eq!( - index.entries()[range].iter().map(|e| e.path(index)).collect::>(), - expected, - "{prefix:?}" - ); -} From ec37db42f0c22268cf400ae193cfb04466f45b0e Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 11 Feb 2024 09:14:11 +0100 Subject: [PATCH 09/15] feat: Provide information about whether or not EOIE and IEOT extensions were available. These are only relevant when reading, and have to be regenerated after writing. During reading, they can speed up performance by allowing multi-threading. --- gix-index/src/access/mod.rs | 8 ++++++++ gix-index/src/decode/entries.rs | 2 ++ gix-index/src/decode/mod.rs | 4 ++++ gix-index/src/extension/decode.rs | 10 ++++++++-- gix-index/src/init.rs | 4 ++++ gix-index/src/lib.rs | 2 ++ gix-index/tests/index/file/read.rs | 2 ++ 7 files changed, 30 insertions(+), 2 deletions(-) diff --git a/gix-index/src/access/mod.rs b/gix-index/src/access/mod.rs index 4bd01579ba..431a47e580 100644 --- a/gix-index/src/access/mod.rs +++ b/gix-index/src/access/mod.rs @@ -557,6 +557,14 @@ impl State { pub fn fs_monitor(&self) -> Option<&extension::FsMonitor> { self.fs_monitor.as_ref() } + /// Return `true` if the end-of-index extension was present when decoding this index. + pub fn had_end_of_index_marker(&self) -> bool { + self.end_of_index_at_decode_time + } + /// Return `true` if the offset-table extension was present when decoding this index. + pub fn had_offset_table(&self) -> bool { + self.offset_table_at_decode_time + } } #[cfg(test)] diff --git a/gix-index/src/decode/entries.rs b/gix-index/src/decode/entries.rs index 414957dc6f..78f544691a 100644 --- a/gix-index/src/decode/entries.rs +++ b/gix-index/src/decode/entries.rs @@ -134,6 +134,8 @@ fn load_one<'a>( (path, skip_padding(data, first_byte_of_entry)) }; + // TODO(perf): for some reason, this causes tremendous `memmove` time even though the backing + // has enough capacity most of the time. path_backing.extend_from_slice(path); data }; diff --git a/gix-index/src/decode/mod.rs b/gix-index/src/decode/mod.rs index d4a4d83328..aea189248b 100644 --- a/gix-index/src/decode/mod.rs +++ b/gix-index/src/decode/mod.rs @@ -236,6 +236,8 @@ impl State { untracked, fs_monitor, is_sparse: is_sparse_from_ext, // a marker is needed in case there are no directories + end_of_index, + offset_table, } = ext; is_sparse |= is_sparse_from_ext; @@ -248,6 +250,8 @@ impl State { path_backing, is_sparse, + end_of_index_at_decode_time: end_of_index, + offset_table_at_decode_time: offset_table, tree, link, resolve_undo, diff --git a/gix-index/src/extension/decode.rs b/gix-index/src/extension/decode.rs index af032f4e3b..fa0a624104 100644 --- a/gix-index/src/extension/decode.rs +++ b/gix-index/src/extension/decode.rs @@ -50,8 +50,12 @@ pub(crate) fn all( extension::fs_monitor::SIGNATURE => { ext.fs_monitor = extension::fs_monitor::decode(ext_data); } - extension::end_of_index_entry::SIGNATURE => {} // skip already done - extension::index_entry_offset_table::SIGNATURE => {} // not relevant/obtained already + extension::end_of_index_entry::SIGNATURE => { + ext.end_of_index = true; + } // skip already done + extension::index_entry_offset_table::SIGNATURE => { + ext.offset_table = true; + } // not relevant/obtained already mandatory if mandatory[0].is_ascii_lowercase() => match mandatory { extension::link::SIGNATURE => ext.link = extension::link::decode(ext_data, object_hash)?.into(), extension::sparse::SIGNATURE => { @@ -77,4 +81,6 @@ pub(crate) struct Outcome { pub untracked: Option, pub fs_monitor: Option, pub is_sparse: bool, + pub offset_table: bool, + pub end_of_index: bool, } diff --git a/gix-index/src/init.rs b/gix-index/src/init.rs index 17d4ebbe16..ecb1f0b13a 100644 --- a/gix-index/src/init.rs +++ b/gix-index/src/init.rs @@ -26,6 +26,8 @@ mod from_tree { resolve_undo: None, untracked: None, fs_monitor: None, + offset_table_at_decode_time: false, + end_of_index_at_decode_time: false, } } /// Create an index [`State`] by traversing `tree` recursively, accessing sub-trees @@ -63,6 +65,8 @@ mod from_tree { resolve_undo: None, untracked: None, fs_monitor: None, + offset_table_at_decode_time: false, + end_of_index_at_decode_time: false, }) } } diff --git a/gix-index/src/lib.rs b/gix-index/src/lib.rs index 9e993e3232..96a218c635 100644 --- a/gix-index/src/lib.rs +++ b/gix-index/src/lib.rs @@ -128,6 +128,8 @@ pub struct State { is_sparse: bool, // Extensions + end_of_index_at_decode_time: bool, + offset_table_at_decode_time: bool, tree: Option, link: Option, resolve_undo: Option, diff --git a/gix-index/tests/index/file/read.rs b/gix-index/tests/index/file/read.rs index 0745bd8e12..53387266f4 100644 --- a/gix-index/tests/index/file/read.rs +++ b/gix-index/tests/index/file/read.rs @@ -227,6 +227,8 @@ fn file_with_conflicts() { fn v4_with_delta_paths_and_ieot_ext() { let file = file("v4_more_files_IEOT"); assert_eq!(file.version(), Version::V4); + assert!(file.had_end_of_index_marker()); + assert!(file.had_offset_table()); assert_eq!(file.entries().len(), 10); for (idx, path) in [ From 3252cfd570b0c0897c51939e1a8c45b35c861c53 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 11 Feb 2024 12:56:23 +0100 Subject: [PATCH 10/15] Implementation of the Git-style directory walk. --- Cargo.lock | 72 +- DEVELOPMENT.md | 2 + crate-status.md | 12 +- gix-dir/Cargo.toml | 20 +- gix-dir/src/entry.rs | 185 ++ gix-dir/src/lib.rs | 63 +- gix-dir/src/walk/classify.rs | 368 +++ gix-dir/src/walk/function.rs | 183 ++ gix-dir/src/walk/mod.rs | 251 ++ gix-dir/src/walk/readdir.rs | 327 ++ gix-dir/tests/dir.rs | 4 + gix-dir/tests/dir_walk_cwd.rs | 33 + .../fixtures/generated-archives/.gitignore | 3 + gix-dir/tests/fixtures/many-symlinks.sh | 19 + gix-dir/tests/fixtures/many.sh | 299 ++ gix-dir/tests/walk/mod.rs | 2736 +++++++++++++++++ gix-dir/tests/walk_utils/mod.rs | 274 ++ 17 files changed, 4815 insertions(+), 36 deletions(-) create mode 100644 gix-dir/src/entry.rs create mode 100644 gix-dir/src/walk/classify.rs create mode 100644 gix-dir/src/walk/function.rs create mode 100644 gix-dir/src/walk/mod.rs create mode 100644 gix-dir/src/walk/readdir.rs create mode 100644 gix-dir/tests/dir.rs create mode 100644 gix-dir/tests/dir_walk_cwd.rs create mode 100644 gix-dir/tests/fixtures/generated-archives/.gitignore create mode 100644 gix-dir/tests/fixtures/many-symlinks.sh create mode 100644 gix-dir/tests/fixtures/many.sh create mode 100644 gix-dir/tests/walk/mod.rs create mode 100644 gix-dir/tests/walk_utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 8b0df36e87..2a604b0b8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -331,7 +331,7 @@ checksum = "fdf6721fb0140e4f897002dd086c06f6c27775df19cfe1fccb21181a48fd2c98" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -581,7 +581,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -951,7 +951,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1709,7 +1709,23 @@ dependencies = [ [[package]] name = "gix-dir" -version = "0.0.0" +version = "0.1.0" +dependencies = [ + "bstr", + "gix-discover 0.30.0", + "gix-fs 0.10.0", + "gix-ignore 0.11.0", + "gix-index 0.30.0", + "gix-object 0.41.0", + "gix-path 0.10.5", + "gix-pathspec", + "gix-testtools", + "gix-trace 0.1.7", + "gix-utils 0.1.9", + "gix-worktree 0.31.0", + "pretty_assertions", + "thiserror", +] [[package]] name = "gix-discover" @@ -2061,7 +2077,7 @@ version = "0.1.3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "trybuild", ] @@ -3314,9 +3330,9 @@ dependencies = [ [[package]] name = "libz-ng-sys" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dd9f43e75536a46ee0f92b758f6b63846e594e86638c61a9251338a65baea63" +checksum = "601c27491de2c76b43c9f52d639b2240bfb9b02112009d3b754bfa90d891492d" dependencies = [ "cmake", "libc", @@ -3324,9 +3340,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" +checksum = "5f526fdd09d99e19742883e43de41e1aa9e36db0c7ab7f935165d611c5cccc66" dependencies = [ "cc", "cmake", @@ -3660,7 +3676,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -3898,9 +3914,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.71" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75cb1540fadbd5b8fbccc4dddad2734eba435053f725621c070711a14bb5f4b8" +checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" dependencies = [ "unicode-ident", ] @@ -3952,9 +3968,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -4349,7 +4365,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -4397,7 +4413,7 @@ checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -4563,7 +4579,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -4585,9 +4601,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.43" +version = "2.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee659fb5f3d355364e1f3e5bc10fb82068efbf824a1e9d1c9504244a6469ad53" +checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb" dependencies = [ "proc-macro2", "quote", @@ -4699,22 +4715,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.52" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a48fd946b02c0a526b2e9481c8e2a17755e47039164a86c4070446e3a4614d" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.52" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7fbe9b594d6568a6a1443250a7e67d80b74e1e96f6d1715e1e21cc1888291d3" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -4875,7 +4891,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -5185,7 +5201,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "wasm-bindgen-shared", ] @@ -5219,7 +5235,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5581,7 +5597,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a8f98915bd..b9d3ee5959 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -309,3 +309,5 @@ GIT_SSH_COMMAND="ssh -VVV" \ git ``` +Consider adding `GIT_TRACE2_PERF=1` (possibly add `GIT_TRACE2_PERF_BRIEF=1` for brevity) as well for statistics and variables +(see [their source for more](https://github.com/git/git/blob/b50a608ba20348cb3dfc16a696816d51780e3f0f/trace2/tr2_sysenv.c#L50). diff --git a/crate-status.md b/crate-status.md index d8b5d3b478..0741fbc069 100644 --- a/crate-status.md +++ b/crate-status.md @@ -612,12 +612,12 @@ A plumbing crate with shared functionality regarding EWAH compressed bitmaps, as A git directory walk. -* [ ] list untracked files - - [ ] `normal` - files and directories - - [ ] `all` - expand to untracked files in untracked directories -* [ ] list ignored files - - [ ] `matching` mode (show every ignored file, do not aggregate into parent directory) - - [ ] `traditional` mode (aggregate all ignored files of a folder into ignoring the folder itself) +* [x] list untracked files +* [x] list ignored files +* [x] collapsing of untracked and ignored directories +* [x] pathspec based filtering +* [ ] multi-threaded initialization of icase hash table is always used to accelerate index lookups, even if ignoreCase = false for performance +* [ ] special handling of submodules (for now, submodules or nested repositories are detected, but they can't be walked into naturally) * [ ] accelerated walk with `untracked`-cache (as provided by `UNTR` extension of `gix_index::File`) ### gix-index diff --git a/gix-dir/Cargo.toml b/gix-dir/Cargo.toml index 52ff0efb4f..69d2f4c46a 100644 --- a/gix-dir/Cargo.toml +++ b/gix-dir/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gix-dir" -version = "0.0.0" +version = "0.1.0" repository = "https://github.com/Byron/gitoxide" license = "MIT OR Apache-2.0" description = "A crate of the gitoxide project dealing with directory walks" @@ -12,3 +12,21 @@ rust-version = "1.65" doctest = false [dependencies] +gix-trace = { version = "^0.1.7", path = "../gix-trace" } +gix-index = { version = "^0.30.0", path = "../gix-index" } +gix-discover = { version = "^0.30.0", path = "../gix-discover" } +gix-fs = { version = "^0.10.0", path = "../gix-fs" } +gix-path = { version = "^0.10.4", path = "../gix-path" } +gix-pathspec = { version = "^0.6.0", path = "../gix-pathspec" } +gix-worktree = { version = "^0.31.0", path = "../gix-worktree", default-features = false } +gix-object = { version = "^0.41.0", path = "../gix-object" } +gix-ignore = { version = "^0.11.0", path = "../gix-ignore" } +gix-utils = { version = "^0.1.9", path = "../gix-utils", features = ["bstr"] } + +bstr = { version = "1.5.0", default-features = false } +thiserror = "1.0.56" + +[dev-dependencies] +gix-testtools = { path = "../tests/tools" } +gix-fs = { path = "../gix-fs" } +pretty_assertions = "1.4.0" diff --git a/gix-dir/src/entry.rs b/gix-dir/src/entry.rs new file mode 100644 index 0000000000..816acf8a42 --- /dev/null +++ b/gix-dir/src/entry.rs @@ -0,0 +1,185 @@ +use crate::walk::ForDeletionMode; +use crate::{Entry, EntryRef}; +use std::borrow::Cow; + +/// The kind of the entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub enum Kind { + /// The entry is a blob, executable or not. + File, + /// The entry is a symlink. + Symlink, + /// A directory that contains no file or directory. + EmptyDirectory, + /// The entry is an ordinary directory. + /// + /// Note that since we don't check for bare repositories, this could in fact be a collapsed + /// bare repository. To be sure, check it again with [`gix_discover::is_git()`] and act accordingly. + Directory, + /// The entry is a directory which *contains* a `.git` folder. + Repository, +} + +/// The kind of entry as obtained from a directory. +/// +/// The order of variants roughly relates from cheap-to-compute to most expensive, as each level needs more tests to assert. +/// Thus, `DotGit` is the cheapest, while `Untracked` is among the most expensive and one of the major outcomes of any +/// [`walk`](crate::walk()) run. +/// For example, if an entry was `Pruned`, we effectively don't know if it would have been `Untracked` as well as we stopped looking. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub enum Status { + /// The filename of an entry was `.git`, which is generally pruned. + DotGit, + /// The provided pathspec prevented further processing as the path didn't match. + /// If this happens, no further checks are done so we wouldn't know if the path is also ignored for example (by mention in `.gitignore`). + Pruned, + /// Always in conjunction with a directory on disk that is also known as cone-mode sparse-checkout exclude marker - i.e. a directory + /// that is excluded, so its whole content is excluded and not checked out nor is part of the index. + TrackedExcluded, + /// The entry is tracked in Git. + Tracked, + /// The entry is ignored as per `.gitignore` files and their rules. + /// + /// If this is a directory, then its entire contents is ignored. Otherwise, possibly due to configuration, individual ignored files are listed. + Ignored(gix_ignore::Kind), + /// The entry is not tracked by git yet, it was not found in the [index](gix_index::State). + /// + /// If it's a directory, the entire directory contents is untracked. + Untracked, +} + +/// Describe how a pathspec pattern matched. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, Ord, PartialOrd)] +pub enum PathspecMatch { + /// The match happened because there wasn't any pattern, which matches all, or because there was a nil pattern or one with an empty path. + /// Thus this is not a match by merit. + Always, + /// A match happened, but the pattern excludes everything it matches, which means this entry was excluded. + Excluded, + /// The first part of a pathspec matches, like `dir/` that matches `dir/a`. + Prefix, + /// The whole pathspec matched and used a wildcard match, like `a/*` matching `a/file`. + WildcardMatch, + /// The entire pathspec matched, letter by letter, e.g. `a/file` matching `a/file`. + Verbatim, +} + +impl PathspecMatch { + pub(crate) fn should_ignore(&self) -> bool { + match self { + PathspecMatch::Always | PathspecMatch::Excluded => true, + PathspecMatch::Prefix | PathspecMatch::WildcardMatch | PathspecMatch::Verbatim => false, + } + } +} + +impl From for PathspecMatch { + fn from(kind: gix_pathspec::search::MatchKind) -> Self { + match kind { + gix_pathspec::search::MatchKind::Always => Self::Always, + gix_pathspec::search::MatchKind::Prefix => Self::Prefix, + gix_pathspec::search::MatchKind::WildcardMatch => Self::WildcardMatch, + gix_pathspec::search::MatchKind::Verbatim => Self::Verbatim, + } + } +} + +impl EntryRef<'_> { + /// Strip the lifetime to obtain a fully owned copy. + pub fn to_owned(&self) -> Entry { + Entry { + rela_path: self.rela_path.clone().into_owned(), + status: self.status, + disk_kind: self.disk_kind, + index_kind: self.index_kind, + pathspec_match: self.pathspec_match, + } + } + + /// Turn this instance into a fully owned copy. + pub fn into_owned(self) -> Entry { + Entry { + rela_path: self.rela_path.into_owned(), + status: self.status, + disk_kind: self.disk_kind, + index_kind: self.index_kind, + pathspec_match: self.pathspec_match, + } + } +} + +impl Entry { + /// Obtain an [`EntryRef`] from this instance. + pub fn to_ref(&self) -> EntryRef<'_> { + EntryRef { + rela_path: Cow::Borrowed(self.rela_path.as_ref()), + status: self.status, + disk_kind: self.disk_kind, + index_kind: self.index_kind, + pathspec_match: self.pathspec_match, + } + } +} + +impl From for Kind { + fn from(value: std::fs::FileType) -> Self { + if value.is_dir() { + Kind::Directory + } else if value.is_symlink() { + Kind::Symlink + } else { + Kind::File + } + } +} + +impl Status { + /// Return true if this status is considered pruned. A pruned entry is typically hidden from view due to a pathspec. + pub fn is_pruned(&self) -> bool { + match self { + Status::DotGit | Status::TrackedExcluded | Status::Pruned => true, + Status::Ignored(_) | Status::Untracked | Status::Tracked => false, + } + } + /// Return `true` if `file_type` is a directory on disk and isn't ignored, and is not a repository. + /// This implements the default rules of `git status`, which is good for a minimal traversal through + /// tracked and non-ignored portions of a worktree. + /// `for_deletion` is used to determine if recursion into a directory is allowed even though it otherwise wouldn't be. + /// + /// Use `pathspec_match` to determine if a pathspec matches in any way, affecting the decision to recurse. + pub fn can_recurse( + &self, + file_type: Option, + pathspec_match: Option, + for_deletion: Option, + ) -> bool { + let is_dir_on_disk = file_type.map_or(false, |ft| ft.is_recursable_dir()); + if !is_dir_on_disk { + return false; + } + match self { + Status::DotGit | Status::TrackedExcluded | Status::Pruned => false, + Status::Ignored(_) => { + for_deletion.map_or(false, |fd| { + matches!( + fd, + ForDeletionMode::FindNonBareRepositoriesInIgnoredDirectories + | ForDeletionMode::FindRepositoriesInIgnoredDirectories + ) + }) || pathspec_match.map_or(false, |m| !m.should_ignore()) + } + Status::Untracked | Status::Tracked => true, + } + } +} + +impl Kind { + fn is_recursable_dir(&self) -> bool { + matches!(self, Kind::Directory) + } + + /// Return `true` if this is a directory on disk. Note that this is true for repositories as well. + pub fn is_dir(&self) -> bool { + matches!(self, Kind::EmptyDirectory | Kind::Directory | Kind::Repository) + } +} diff --git a/gix-dir/src/lib.rs b/gix-dir/src/lib.rs index fed43000f8..48fa269b08 100644 --- a/gix-dir/src/lib.rs +++ b/gix-dir/src/lib.rs @@ -1,3 +1,64 @@ //! A crate for handling a git-style directory walk. -#![deny(rust_2018_idioms)] +#![deny(missing_docs, rust_2018_idioms)] #![forbid(unsafe_code)] + +use bstr::{BStr, BString}; +use std::borrow::Cow; + +/// A directory entry, typically obtained using [`walk()`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct EntryRef<'a> { + /// The repository-relative path at which the file or directory could be found, with unix-style component separators. + /// + /// To obtain the respective file, join it with the `worktree_root` passed to [`walk()`]. + /// The rationale here is that this is a compressed and normalized version compared to the paths we would otherwise get, + /// which is preferable especially when converted to [`Entry`] due to lower memory requirements. + /// + /// This also means that the original path to be presented to the user needs to be computed separately, as it's also relative + /// to their prefix, i.e. their current working directory within the repository. + /// + /// Note that this value can be empty if information about the `worktree_root` is provided, which is fine as + /// [joining](std::path::Path::join) with an empty string is a no-op. + /// + /// Note that depending on the way entries are emitted, even refs might already contain an owned `rela_path`, for use with + /// [into_owned()](EntryRef::into_owned()) + /// + pub rela_path: Cow<'a, BStr>, + /// The status of entry, most closely related to what we know from `git status`, but not the same. + /// + /// Note that many entries with status `Pruned` will not show up as their kind hasn't yet been determined when they were + /// pruned very early on. + pub status: entry::Status, + /// Further specify the what the entry is on disk, similar to a file mode. + /// This is `None` if the entry was pruned by a pathspec that could not match, as we then won't invest the time to obtain + /// the kind of the entry on disk. + pub disk_kind: Option, + /// The kind of entry according to the index, if tracked. *Usually* the same as `disk_kind`. + pub index_kind: Option, + /// Determines how the pathspec matched. + /// Can also be `None` if no pathspec matched, or if the status check stopped prior to checking for pathspec matches which is the case for [`entry::Status::DotGit`]. + /// Note that it can also be `Some(PathspecMatch::Excluded)` if a negative pathspec matched. + pub pathspec_match: Option, +} + +/// Just like [`EntryRef`], but with all fields owned (and thus without a lifetime to consider). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct Entry { + /// See [EntryRef::rela_path] for details. + pub rela_path: BString, + /// The status of entry, most closely related to what we know from `git status`, but not the same. + pub status: entry::Status, + /// Further specify the what the entry is on disk, similar to a file mode. + pub disk_kind: Option, + /// The kind of entry according to the index, if tracked. *Usually* the same as `disk_kind`. + pub index_kind: Option, + /// Indicate how the pathspec matches the entry. See more in [`EntryRef::pathspec_match`]. + pub pathspec_match: Option, +} + +/// +pub mod entry; + +/// +pub mod walk; +pub use walk::function::walk; diff --git a/gix-dir/src/walk/classify.rs b/gix-dir/src/walk/classify.rs new file mode 100644 index 0000000000..523e0690fb --- /dev/null +++ b/gix-dir/src/walk/classify.rs @@ -0,0 +1,368 @@ +use crate::{entry, Entry}; + +use crate::entry::PathspecMatch; +use crate::walk::{Context, Error, ForDeletionMode, Options}; +use bstr::{BStr, BString, ByteSlice}; +use std::ffi::OsStr; +use std::path::{Component, Path, PathBuf}; + +/// Classify the `worktree_relative_root` path and return the first `PathKind` that indicates that +/// it isn't a directory, leaving `buf` with the path matching the returned `PathKind`, +/// which is at most equal to `worktree_relative_root`. +pub fn root( + worktree_root: &Path, + buf: &mut BString, + worktree_relative_root: &Path, + options: Options, + ctx: &mut Context<'_>, +) -> Result { + buf.clear(); + let mut last_length = None; + let mut path_buf = worktree_root.to_owned(); + // These initial values kick in if worktree_relative_root.is_empty(); + let mut out = None; + for component in worktree_relative_root + .components() + .chain(if worktree_relative_root.as_os_str().is_empty() { + Some(Component::Normal(OsStr::new(""))) + } else { + None + }) + { + if last_length.is_some() { + buf.push(b'/'); + } + path_buf.push(component); + buf.extend_from_slice(gix_path::os_str_into_bstr(component.as_os_str()).expect("no illformed UTF8")); + let file_kind = path_buf.symlink_metadata().map(|m| m.file_type().into()).ok(); + + let res = path( + &mut path_buf, + buf, + last_length.map(|l| l + 1 /* slash */).unwrap_or_default(), + file_kind, + || None, + options, + ctx, + )?; + out = Some(res); + if !res + .status + .can_recurse(res.disk_kind, res.pathspec_match, options.for_deletion) + { + break; + } + last_length = Some(buf.len()); + } + Ok(out.expect("One iteration of the loop at least")) +} +/// The product of [`path()`] calls. +#[derive(Debug, Copy, Clone)] +pub struct Outcome { + /// The computed status of an entry. It can be seen as aggregate of things we know about an entry. + pub status: entry::Status, + /// What the entry is on disk, or `None` if we aborted the classification early. + /// + /// Note that the index is used to avoid disk access provided its entries are marked uptodate + /// (possibly by a prior call to update the status). + pub disk_kind: Option, + /// What the entry looks like in the index, or `None` if we aborted early. + pub index_kind: Option, + /// If a pathspec matched, this is how it matched. Maybe `None` if computation didn't see the need to evaluate it. + pub pathspec_match: Option, +} + +impl Outcome { + fn with_status(mut self, status: entry::Status) -> Self { + self.status = status; + self + } + + fn with_kind(mut self, disk_kind: Option, index_kind: Option) -> Self { + self.disk_kind = disk_kind; + self.index_kind = index_kind; + self + } +} + +impl From<&Entry> for Outcome { + fn from(e: &Entry) -> Self { + Outcome { + status: e.status, + disk_kind: e.disk_kind, + index_kind: e.index_kind, + pathspec_match: e.pathspec_match, + } + } +} + +/// Figure out what to do with `rela_path`, provided as worktree-relative path, with `disk_file_type` if it is known already +/// as it helps to match pathspecs correctly, which can be different for directories. +/// `path` is a disk-accessible variant of `rela_path` which is within the `worktree_root`, and will be modified temporarily but remain unchanged. +/// +/// Note that `rela_path` is used as buffer for convenience, but will be left as is when this function returns. +/// `filename_start_idx` is the index at which the filename begins, i.e. `a/b` has `2` as index. +/// It may resemble a directory on the way to a leaf (like a file) +/// +/// Returns `(status, file_kind, pathspec_matches_how)` to identify the `status` on disk, along with a classification `file_kind`, +/// and if `file_kind` is not a directory, the way the pathspec matched with `pathspec_matches_how`. +pub fn path( + path: &mut PathBuf, + rela_path: &mut BString, + filename_start_idx: usize, + disk_kind: Option, + on_demand_disk_kind: impl FnOnce() -> Option, + Options { + ignore_case, + recurse_repositories, + emit_ignored, + for_deletion, + classify_untracked_bare_repositories, + .. + }: Options, + ctx: &mut Context<'_>, +) -> Result { + let mut out = Outcome { + status: entry::Status::DotGit, + disk_kind, + index_kind: None, + pathspec_match: None, + }; + if is_eq(rela_path[filename_start_idx..].as_bstr(), ".git", ignore_case) { + return Ok(out); + } + let pathspec_could_match = rela_path.is_empty() + || ctx + .pathspec + .can_match_relative_path(rela_path.as_bstr(), disk_kind.map(|ft| ft.is_dir())); + if !pathspec_could_match { + return Ok(out.with_status(entry::Status::Pruned)); + } + + let (uptodate_index_kind, index_kind, mut maybe_status) = resolve_file_type_with_index( + rela_path, + ctx.index, + ctx.ignore_case_index_lookup.filter(|_| ignore_case), + ); + let mut kind = uptodate_index_kind.or(disk_kind).or_else(on_demand_disk_kind); + + maybe_status = maybe_status + .or_else(|| (index_kind.map(|k| k.is_dir()) == kind.map(|k| k.is_dir())).then_some(entry::Status::Tracked)); + + // We always check the pathspec to have the value filled in reliably. + out.pathspec_match = ctx + .pathspec + .pattern_matching_relative_path( + rela_path.as_bstr(), + disk_kind.map(|ft| ft.is_dir()), + ctx.pathspec_attributes, + ) + .map(|m| { + if m.is_excluded() { + PathspecMatch::Excluded + } else { + 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 { + return current_kind; + } + if find_harder { + let mut is_nested_repo = gix_discover::is_git(path).is_ok(); + if is_nested_repo { + let git_dir_is_our_own = + gix_path::realpath_opts(path, ctx.current_dir, gix_path::realpath::MAX_SYMLINKS) + .ok() + .map_or(false, |realpath_candidate| realpath_candidate == ctx.git_dir_realpath); + is_nested_repo = !git_dir_is_our_own; + } + if is_nested_repo { + return Some(entry::Kind::Repository); + } + } + path.push(gix_discover::DOT_GIT_DIR); + let mut is_nested_nonbare_repo = gix_discover::is_git(path).is_ok(); + if is_nested_nonbare_repo { + let git_dir_is_our_own = gix_path::realpath_opts(path, ctx.current_dir, gix_path::realpath::MAX_SYMLINKS) + .ok() + .map_or(false, |realpath_candidate| realpath_candidate == ctx.git_dir_realpath); + is_nested_nonbare_repo = !git_dir_is_our_own; + } + path.pop(); + + if is_nested_nonbare_repo { + Some(entry::Kind::Repository) + } else { + current_kind + } + }; + + if let Some(excluded) = ctx + .excludes + .as_mut() + .map_or(Ok(None), |stack| { + stack + .at_entry(rela_path.as_bstr(), kind.map(|ft| ft.is_dir()), ctx.objects) + .map(|platform| platform.excluded_kind()) + }) + .map_err(Error::ExcludesAccess)? + { + if emit_ignored.is_some() { + if kind.map_or(false, |d| d.is_dir()) && out.pathspec_match.is_none() { + // we have patterns that didn't match at all. Try harder. + out.pathspec_match = ctx + .pathspec + .directory_matches_prefix(rela_path.as_bstr(), true) + .then_some(PathspecMatch::Prefix); + } + if matches!( + for_deletion, + Some( + ForDeletionMode::FindNonBareRepositoriesInIgnoredDirectories + | ForDeletionMode::FindRepositoriesInIgnoredDirectories + ) + ) { + kind = maybe_upgrade_to_repository( + kind, + matches!( + for_deletion, + Some(ForDeletionMode::FindRepositoriesInIgnoredDirectories) + ), + ); + } + } + return Ok(out + .with_status(entry::Status::Ignored(excluded)) + .with_kind(kind, index_kind)); + } + + debug_assert!(maybe_status.is_none()); + let mut status = entry::Status::Untracked; + + if kind.map_or(false, |ft| ft.is_dir()) { + kind = maybe_upgrade_to_repository(kind, classify_untracked_bare_repositories); + } else if out.pathspec_match.is_none() { + status = entry::Status::Pruned; + } + Ok(out.with_status(status).with_kind(kind, index_kind)) +} + +/// Note that `rela_path` is used as buffer for convenience, but will be left as is when this function returns. +/// Also note `maybe_file_type` will be `None` for entries that aren't up-to-date and files, for directories at least one entry must be uptodate. +/// Returns `(maybe_file_type, Option, Option(TrackedExcluded)`, with the last option being set only for sparse directories. +/// `tracked_exclued` indicates it's a sparse directory was found. +/// `index_file_type` is the type of `rela_path` as available in the index. +/// +/// ### Shortcoming +/// +/// In case-insensitive mode, if there is an entry `d` and a `D/a` both in the index, we will always find the file `d` first, and always consider +/// the entry as not uptodate, while classifying it as file (the first one we found). As quite a huge exception, this isn't properly represented +/// in the data model, and we emit a trace to make it more obvious when it happens, in case this leads to less expected results. +fn resolve_file_type_with_index( + rela_path: &mut BString, + index: &gix_index::State, + ignore_case: Option<&gix_index::AccelerateLookup<'_>>, +) -> (Option, Option, Option) { + // TODO: either get this to work for icase as well, or remove the need for it. Logic is different in both branches. + let mut special_status = None; + + fn entry_to_kinds(entry: &gix_index::Entry) -> (Option, Option) { + let kind = if entry.mode.is_submodule() { + entry::Kind::Repository.into() + } else if entry.mode.contains(gix_index::entry::Mode::FILE) { + entry::Kind::File.into() + } else if entry.mode.contains(gix_index::entry::Mode::SYMLINK) { + entry::Kind::Symlink.into() + } else { + None + }; + ( + kind.filter(|_| entry.flags.contains(gix_index::entry::Flags::UPTODATE)), + kind, + ) + } + + fn icase_directory_to_kinds(dir: Option<&gix_index::Entry>) -> (Option, Option) { + let index_kind = dir.map(|_| entry::Kind::Directory); + let uptodate_kind = dir + .filter(|entry| entry.flags.contains(gix_index::entry::Flags::UPTODATE)) + .map(|_| entry::Kind::Directory); + (uptodate_kind, index_kind) + } + + // TODO(perf): multi-threaded hash-table so it's always used, even for case-sensitive lookups, just like Git does it. + let (uptodate_kind, index_kind) = if let Some(accelerate) = ignore_case { + match index.entry_by_path_icase(rela_path.as_bstr(), true, accelerate) { + None => { + icase_directory_to_kinds(index.entry_closest_to_directory_icase(rela_path.as_bstr(), true, accelerate)) + } + Some(entry) => { + let icase_dir = index.entry_closest_to_directory_icase(rela_path.as_bstr(), true, accelerate); + let directory_matches_exactly = icase_dir.map_or(false, |dir| { + let path = dir.path(index); + let slash_idx = path.rfind_byte(b'/').expect("dir"); + path[..slash_idx].as_bstr() == rela_path + }); + if directory_matches_exactly { + icase_directory_to_kinds(icase_dir) + } else { + entry_to_kinds(entry) + } + } + } + } else { + match index.entry_by_path(rela_path.as_bstr()) { + None => { + rela_path.push(b'/'); + let res = index.prefixed_entries_range(rela_path.as_bstr()); + rela_path.pop(); + + let mut one_index_signalling_with_cone = None; + let mut all_excluded_from_worktree_non_cone = false; + let is_tracked = res.is_some(); + let kind = res + .filter(|range| { + if range.len() == 1 { + one_index_signalling_with_cone = range.start.into(); + } + let entries = &index.entries()[range.clone()]; + let any_up_to_date = entries + .iter() + .any(|e| e.flags.contains(gix_index::entry::Flags::UPTODATE)); + if !any_up_to_date && one_index_signalling_with_cone.is_none() { + all_excluded_from_worktree_non_cone = entries + .iter() + .all(|e| e.flags.contains(gix_index::entry::Flags::SKIP_WORKTREE)); + } + any_up_to_date + }) + .map(|_| entry::Kind::Directory); + + if all_excluded_from_worktree_non_cone + || one_index_signalling_with_cone + .filter(|_| kind.is_none()) + .map_or(false, |idx| index.entries()[idx].mode.is_sparse()) + { + special_status = Some(entry::Status::TrackedExcluded); + } + (kind, is_tracked.then_some(entry::Kind::Directory)) + } + Some(entry) => entry_to_kinds(entry), + } + }; + (uptodate_kind, index_kind, special_status) +} + +fn is_eq(lhs: &BStr, rhs: impl AsRef, ignore_case: bool) -> bool { + if ignore_case { + lhs.eq_ignore_ascii_case(rhs.as_ref().as_ref()) + } else { + lhs == rhs.as_ref() + } +} diff --git a/gix-dir/src/walk/function.rs b/gix-dir/src/walk/function.rs new file mode 100644 index 0000000000..d8015a98fd --- /dev/null +++ b/gix-dir/src/walk/function.rs @@ -0,0 +1,183 @@ +use std::borrow::Cow; +use std::path::{Path, PathBuf}; + +use bstr::{BStr, BString, ByteSlice}; + +use crate::walk::{classify, readdir, Action, Context, Delegate, Error, ForDeletionMode, Options, Outcome}; +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. +/// * `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. +/// +/// ### Performance Notes +/// +/// In theory, parallel directory traversal can be significantly faster, and what's possible for our current +/// `gix_features::fs::WalkDir` implementation is to abstract a `filter_entry()` method so it works both for +/// the iterator from the `walkdir` crate as well as from `jwalk`. However, doing so as initial version +/// has the risk of not being significantly harder if not impossible to implement as flow-control is very +/// limited. +/// +/// Thus the decision was made to start out with something akin to the Git implementation, get all tests and +/// baseline comparison to pass, and see if an iterator with just `filter_entry` would be capable of dealing with +/// it. Note that `filter_entry` is the only just-in-time traversal control that `walkdir` offers, even though +/// one could consider switching to `jwalk` and just use its single-threaded implementation if a unified interface +/// is necessary to make this work - `jwalk` has a more powerful API for this to work. +/// +/// If that was the case, we are talking about 0.5s for single-threaded traversal (without doing any extra work) +/// 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 _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 out = Outcome::default(); + let mut buf = BString::default(); + let root_info = classify::root( + worktree_root, + &mut buf, + worktree_root_relative.as_ref(), + options, + &mut ctx, + )?; + if !can_recurse(buf.as_bstr(), root_info, options.for_deletion, delegate) { + if buf.is_empty() && !matches!(root_info.disk_kind, Some(entry::Kind::Directory { .. })) { + return Err(Error::WorktreeRootIsFile { root: root.to_owned() }); + } + if options.precompose_unicode { + buf = gix_utils::str::precompose_bstr(buf.into()).into_owned(); + } + let _ = emit_entry( + Cow::Borrowed(buf.as_bstr()), + root_info, + None, + options, + &mut out, + delegate, + ); + return Ok(out); + } + + let mut state = readdir::State::default(); + let _ = readdir::recursive( + root == worktree_root, + &mut current, + &mut buf, + root_info, + &mut ctx, + options, + delegate, + &mut out, + &mut state, + )?; + assert_eq!(state.on_hold.len(), 0, "BUG: must be fully consumed"); + gix_trace::debug!(statistics = ?out); + Ok(out) +} + +/// Note that we only check symlinks on the way from `worktree_root` to `root`, +/// so `worktree_root` may go through a symlink. +/// Returns `(worktree_root, normalized_worktree_relative_root)`. +fn assure_no_symlink_in_root<'root>( + worktree_root: &Path, + 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 = gix_path::normalize(worktree_relative.into(), Path::new("")) + .ok_or(Error::NormalizeRoot { root: root.to_owned() })?; + + for (idx, component) in worktree_relative.components().enumerate() { + current.push(component); + let meta = current.symlink_metadata().map_err(|err| Error::SymlinkMetadata { + source: err, + path: current.to_owned(), + })?; + if meta.is_symlink() { + return Err(Error::SymlinkInRoot { + root: root.to_owned(), + worktree_root: worktree_root.to_owned(), + component_index: idx, + }); + } + } + Ok((current, worktree_relative)) +} + +pub(super) fn can_recurse( + rela_path: &BStr, + info: classify::Outcome, + for_deletion: Option, + delegate: &mut dyn Delegate, +) -> bool { + if info.disk_kind.map_or(true, |k| !k.is_dir()) { + return false; + } + let entry = EntryRef { + rela_path: Cow::Borrowed(rela_path), + status: info.status, + disk_kind: info.disk_kind, + index_kind: info.index_kind, + pathspec_match: info.pathspec_match, + }; + delegate.can_recurse(entry, for_deletion) +} + +/// Possibly emit an entry to `for_each` in case the provided information makes that possible. +#[allow(clippy::too_many_arguments)] +pub(super) fn emit_entry( + rela_path: Cow<'_, BStr>, + info: classify::Outcome, + dir_status: Option, + Options { + emit_pruned, + emit_tracked, + emit_ignored, + emit_empty_directories, + .. + }: Options, + out: &mut Outcome, + delegate: &mut dyn Delegate, +) -> Action { + out.seen_entries += 1; + + if (!emit_empty_directories && info.disk_kind == Some(entry::Kind::EmptyDirectory) + || !emit_tracked && info.status == entry::Status::Tracked) + || emit_ignored.is_none() && matches!(info.status, entry::Status::Ignored(_)) + || !emit_pruned + && (info.status.is_pruned() + || info + .pathspec_match + .map_or(true, |m| m == entry::PathspecMatch::Excluded)) + { + return Action::Continue; + } + + out.returned_entries += 1; + delegate.emit( + EntryRef { + rela_path, + status: info.status, + disk_kind: info.disk_kind, + index_kind: info.index_kind, + pathspec_match: info.pathspec_match, + }, + dir_status, + ) +} diff --git a/gix-dir/src/walk/mod.rs b/gix-dir/src/walk/mod.rs new file mode 100644 index 0000000000..5929d42ca4 --- /dev/null +++ b/gix-dir/src/walk/mod.rs @@ -0,0 +1,251 @@ +use crate::{entry, EntryRef}; +use bstr::BStr; +use std::path::PathBuf; + +/// A type returned by the [`Delegate::emit()`] as passed to [`walk()`](function::walk()). +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[must_use] +pub enum Action { + /// Continue the traversal as normal. + Continue, + /// Do not continue the traversal, but exit it. + Cancel, +} + +/// Ready-made delegate implementations. +pub mod delegate { + use crate::walk::Action; + use crate::{entry, walk, Entry, EntryRef}; + + type Entries = Vec<(Entry, Option)>; + + /// A [`Delegate`](walk::Delegate) implementation that collects all `entries` along with their directory status, if present. + /// + /// Note that this allocates for each entry. + #[derive(Default)] + pub struct Collect { + /// All collected entries, in any order. + pub unorded_entries: Entries, + } + + impl Collect { + /// Return the list of entries that were emitted, sorted ascending by their repository-relative tree path. + pub fn into_entries_by_path(mut self) -> Entries { + self.unorded_entries.sort_by(|a, b| a.0.rela_path.cmp(&b.0.rela_path)); + self.unorded_entries + } + } + + impl walk::Delegate for Collect { + fn emit(&mut self, entry: EntryRef<'_>, dir_status: Option) -> Action { + self.unorded_entries.push((entry.to_owned(), dir_status)); + walk::Action::Continue + } + } +} + +/// A way for the caller to control the traversal based on provided data. +pub trait Delegate { + /// Called for each observed `entry` *inside* a directory, or the directory itself if the traversal is configured + /// to simplify the result (i.e. if every file in a directory is ignored, emit the containing directory instead + /// of each file), or if the root of the traversal passes through a directory that can't be traversed. + /// + /// It will also be called if the `root` in [`walk()`](crate::walk()) itself is matching a particular status, + /// even if it is a file. + /// + /// Note that tracked entries will only be emitted if [`Options::emit_tracked`] is `true`. + /// Further, not all pruned entries will be observable as they might be pruned so early that the kind of + /// 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. + 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. + /// The base implementation will act like Git does by default in `git status` or `git clean`. + /// + /// Use `for_deletion` to specify if the seen entries should ultimately be deleted, which may affect the decision + /// of whether to resource or not. + /// + /// Note that this method will see all directories, even though not all of them may end up being [emitted](Self::emit()). + /// If this method returns `false`, the `entry` will always be emitted. + fn can_recurse(&mut self, entry: EntryRef<'_>, for_deletion: Option) -> bool { + entry + .status + .can_recurse(entry.disk_kind, entry.pathspec_match, for_deletion) + } +} + +/// The way entries are emitted using the [Delegate]. +/// +/// The choice here controls if entries are emitted immediately, or have to be held back. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub enum EmissionMode { + /// Emit each entry as it matches exactly, without doing any kind of simplification. + /// + /// Emissions in this mode are happening as they occour, without any buffering or ordering. + #[default] + Matching, + /// Emit only a containing directory if all of its entries are of the same type. + /// + /// Note that doing so is more expensive as it requires us to keep track of all entries in the directory structure + /// until it's clear what to finally emit. + CollapseDirectory, +} + +/// 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)] +pub enum ForDeletionMode { + /// We will stop traversing into ignored directories which may save a lot of time, but also may include nested repositories + /// which might end up being deleted. + #[default] + IgnoredDirectoriesCanHideNestedRepositories, + /// Instead of skipping over ignored directories entirely, we will dive in and find ignored non-bare repositories + /// so these are emitted separately and prevent collapsing. These are assumed to be a directory with `.git` inside. + /// Only relevant when ignored entries are emitted. + FindNonBareRepositoriesInIgnoredDirectories, + /// This is a more expensive form of the above variant as it finds all repositories, bare or non-bare. + FindRepositoriesInIgnoredDirectories, +} + +/// Options for use in [`walk()`](function::walk()) function. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct Options { + /// If true, the filesystem will store paths as decomposed unicode, i.e. `ä` becomes `"a\u{308}"`, which means that + /// we have to turn these forms back from decomposed to precomposed unicode before storing it in the index or generally + /// using it. This also applies to input received from the command-line, so callers may have to be aware of this and + /// perform conversions accordingly. + /// If false, no conversions will be performed. + pub precompose_unicode: bool, + /// If true, the filesystem ignores the case of input, which makes `A` the same file as `a`. + /// This is also called case-folding. + /// Note that [pathspecs](Context::pathspec) must also be using the same defaults, which makes them match case-insensitive + /// automatically. + pub ignore_case: bool, + /// If `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 recurse_repositories: bool, + /// If `true`, entries that are pruned and whose [Kind](crate::entry::Kind) is known will be emitted. + pub emit_pruned: bool, + /// If `Some(mode)`, entries that are ignored will be emitted according to the given `mode`. + /// If `None`, ignored entries will not be emitted at all. + pub emit_ignored: Option, + /// When the walk is for deletion, this must be `Some(_)` to assure we don't collapse directories that have precious files in + /// them, and otherwise assure that no entries are observable that shouldn't be deleted. + /// If `None`, precious files are treated like expendable files, which is usually what you want when displaying them + /// for addition to the repository, and the collapse of folders can be more generous in relation to ignored files. + pub for_deletion: Option, + /// If `true`, we will not only find non-bare repositories in untracked directories, but also bare ones. + /// + /// Note that this is very costly, but without it, bare repositories will appear like untracked directories when collapsed, + /// and they will be recursed into. + pub classify_untracked_bare_repositories: bool, + /// If `true`, we will also emit entries for tracked items. Otherwise these will remain 'hidden', even if a pathspec directly + /// refers to it. + pub emit_tracked: bool, + /// Controls the way untracked files are emitted. By default, this is happening immediately and without any simplification. + pub emit_untracked: EmissionMode, + /// If `true`, emit empty directories as well. Note that a directory also counts as empty if it has any amount or depth of nested + /// 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, +} + +/// All information that is required to perform a dirwalk, and classify paths properly. +pub struct Context<'a> { + /// The `git_dir` of the parent repository, after a call to [`gix_path::realpath()`]. + /// + /// It's used to help us differentiate our own `.git` directory from nested unrelated repositories, + /// which is needed if `core.worktree` is used to nest the `.git` directory deeper within. + 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. + pub current_dir: &'a std::path::Path, + /// The index to quickly understand if a file or directory is tracked or not. + /// + /// ### Important + /// + /// The index must have been validated so that each entry that is considered up-to-date will have the [gix_index::entry::Flags::UPTODATE] flag + /// set. Otherwise the index entry is not considered and a disk-access may occour which is costly. + pub index: &'a gix_index::State, + /// A utility to lookup index entries faster, and deal with ignore-case handling. + /// + /// Must be set if `ignore_case` is `true`, or else some entries won't be found if their case is different. + /// + /// ### Deviation + /// + /// Git uses a name-based hash (for looking up entries, not directories) even when operating + /// 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. + 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]. + /// If no such filtering is desired, pass an empty `pathspec` which will match everything. + pub pathspec: &'a mut gix_pathspec::Search, + /// The `attributes` callback for use in [gix_pathspec::Search::pattern_matching_relative_path()], which happens when + /// pathspecs use attributes for filtering. + /// If `pathspec` isn't empty, this function may be called if pathspecs perform attribute lookups. + pub pathspec_attributes: &'a mut dyn FnMut( + &BStr, + gix_pathspec::attributes::glob::pattern::Case, + bool, + &mut gix_pathspec::attributes::search::Outcome, + ) -> bool, + /// A way to query the `.gitignore` files to see if a directory or file is ignored. + /// Set to `None` to not perform any work on checking for ignored, which turns previously ignored files into untracked ones, a useful + /// operation when trying to add ignored files to a repository. + 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, +} + +/// Additional information collected as outcome of [`walk()`](function::walk()). +#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] +pub struct Outcome { + /// The amount of calls to read the directory contents. + pub read_dir_calls: u32, + /// The amount of returned entries provided to the callback. This number can be lower than `seen_entries`. + pub returned_entries: usize, + /// The amount of entries, prior to pathspecs filtering them out or otherwise excluding them. + pub seen_entries: u32, +} + +/// The error returned by [`walk()`](function::walk()). +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Worktree root at '{}' is not a directory", root.display())] + 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, + worktree_root: PathBuf, + /// This index starts at 0, with 0 being the first component. + component_index: usize, + }, + #[error("Failed to update the excludes stack to see if a path is excluded")] + ExcludesAccess(std::io::Error), + #[error("Failed to read the directory at '{}'", path.display())] + ReadDir { path: PathBuf, source: std::io::Error }, + #[error("Could not obtain directory entry in root of '{}'", parent_directory.display())] + DirEntry { + parent_directory: PathBuf, + source: std::io::Error, + }, + #[error("Could not obtain filetype of directory entry '{}'", path.display())] + DirEntryFileType { path: PathBuf, source: std::io::Error }, + #[error("Could not obtain symlink metadata on '{}'", path.display())] + SymlinkMetadata { path: PathBuf, source: std::io::Error }, +} + +mod classify; +pub(crate) mod function; +mod readdir; diff --git a/gix-dir/src/walk/readdir.rs b/gix-dir/src/walk/readdir.rs new file mode 100644 index 0000000000..11b6fcbb16 --- /dev/null +++ b/gix-dir/src/walk/readdir.rs @@ -0,0 +1,327 @@ +use bstr::{BStr, BString, ByteSlice}; +use std::borrow::Cow; +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::{entry, walk, Entry}; + +/// ### Deviation +/// +/// 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, + current: &mut PathBuf, + current_bstr: &mut BString, + current_info: classify::Outcome, + ctx: &mut Context<'_>, + opts: Options, + delegate: &mut dyn Delegate, + out: &mut Outcome, + state: &mut State, +) -> Result<(Action, bool), Error> { + out.read_dir_calls += 1; + let entries = gix_fs::read_dir(current, opts.precompose_unicode).map_err(|err| Error::ReadDir { + path: current.to_owned(), + source: err, + })?; + + let mut num_entries = 0; + let mark = state.mark(is_worktree_dir); + let mut prevent_collapse = false; + for entry in entries { + let entry = entry.map_err(|err| Error::DirEntry { + parent_directory: current.to_owned(), + source: err, + })?; + // Important to count right away, otherwise the directory could be seen as empty even though it's not. + // That is, this should be independent of the kind. + num_entries += 1; + + let prev_len = current_bstr.len(); + if prev_len != 0 { + current_bstr.push(b'/'); + } + let file_name = entry.file_name(); + current_bstr.extend_from_slice( + gix_path::try_os_str_into_bstr(Cow::Borrowed(file_name.as_ref())) + .expect("no illformed UTF-8") + .as_ref(), + ); + current.push(file_name); + + let info = classify::path( + current, + current_bstr, + if prev_len == 0 { 0 } else { prev_len + 1 }, + None, + || entry.file_type().ok().map(Into::into), + opts, + ctx, + )?; + + if can_recurse(current_bstr.as_bstr(), info, opts.for_deletion, delegate) { + let (action, subdir_prevent_collapse) = + recursive(false, current, current_bstr, info, ctx, opts, delegate, out, state)?; + prevent_collapse = subdir_prevent_collapse; + if action != Action::Continue { + break; + } + } 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); + if action != Action::Continue { + return Ok((action, prevent_collapse)); + } + } + current_bstr.truncate(prev_len); + current.pop(); + } + + let res = mark.reduce_held_entries( + num_entries, + state, + &mut prevent_collapse, + current_bstr.as_bstr(), + current_info, + opts, + out, + ctx, + delegate, + ); + 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, +} + +impl State { + /// Hold the entry with the given `status` if it's a candidate for collapsing the containing directory. + fn held_for_directory_collapse(&mut self, rela_path: &BStr, info: classify::Outcome, opts: &Options) -> bool { + if opts.should_hold(info.status) { + self.on_hold.push(Entry { + rela_path: rela_path.to_owned(), + status: info.status, + disk_kind: info.disk_kind, + index_kind: info.index_kind, + pathspec_match: info.pathspec_match, + }); + true + } else { + false + } + } + + /// 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 { + Mark { + start_index: self.on_hold.len(), + is_worktree_dir, + } + } +} + +struct Mark { + start_index: usize, + is_worktree_dir: bool, +} + +impl Mark { + #[allow(clippy::too_many_arguments)] + fn reduce_held_entries( + mut self, + num_entries: usize, + state: &mut State, + prevent_collapse: &mut bool, + dir_rela_path: &BStr, + dir_info: classify::Outcome, + opts: Options, + out: &mut walk::Outcome, + ctx: &mut Context<'_>, + delegate: &mut dyn walk::Delegate, + ) -> walk::Action { + if num_entries == 0 { + let empty_info = classify::Outcome { + disk_kind: if num_entries == 0 { + assert_ne!( + dir_info.disk_kind, + Some(entry::Kind::Repository), + "BUG: it shouldn't be possible to classify an empty dir as repository" + ); + Some(entry::Kind::EmptyDirectory) + } else { + dir_info.disk_kind + }, + ..dir_info + }; + if opts.should_hold(empty_info.status) { + state.on_hold.push(Entry { + rela_path: dir_rela_path.to_owned(), + status: empty_info.status, + disk_kind: empty_info.disk_kind, + index_kind: empty_info.index_kind, + pathspec_match: empty_info.pathspec_match, + }); + Action::Continue + } else { + emit_entry(Cow::Borrowed(dir_rela_path), empty_info, None, opts, out, delegate) + } + } else if *prevent_collapse { + self.emit_all_held(state, opts, out, delegate) + } else if let Some(action) = self.try_collapse( + dir_rela_path, + dir_info, + state, + prevent_collapse, + out, + opts, + ctx, + delegate, + ) { + action + } else { + self.emit_all_held(state, opts, out, delegate) + } + } + + fn emit_all_held( + &mut self, + state: &mut State, + opts: Options, + out: &mut walk::Outcome, + delegate: &mut dyn walk::Delegate, + ) -> Action { + for entry in state.on_hold.drain(self.start_index..) { + let info = classify::Outcome::from(&entry); + let action = emit_entry(Cow::Owned(entry.rela_path), info, None, opts, out, delegate); + if action != Action::Continue { + return action; + } + } + Action::Continue + } + + #[allow(clippy::too_many_arguments)] + fn try_collapse( + &self, + dir_rela_path: &BStr, + dir_info: classify::Outcome, + state: &mut State, + prevent_collapse: &mut bool, + out: &mut walk::Outcome, + opts: Options, + ctx: &mut Context<'_>, + delegate: &mut dyn walk::Delegate, + ) -> Option { + if self.is_worktree_dir { + return None; + } + let (mut expendable, mut precious, mut untracked, mut entries, mut matching_entries) = (0, 0, 0, 0, 0); + for (kind, status, pathspec_match) in state.on_hold[self.start_index..] + .iter() + .map(|e| (e.disk_kind, e.status, e.pathspec_match)) + { + entries += 1; + if kind == Some(entry::Kind::Repository) { + *prevent_collapse = true; + return None; + } + if pathspec_match.map_or(false, |m| { + matches!(m, PathspecMatch::Verbatim | PathspecMatch::Excluded) + }) { + return None; + } + matching_entries += usize::from(pathspec_match.map_or(false, |m| !m.should_ignore())); + match status { + Status::DotGit | Status::Pruned | Status::TrackedExcluded => { + unreachable!("BUG: pruned aren't ever held, check `should_hold()`") + } + Status::Tracked => { /* causes the folder not to be collapsed */ } + Status::Ignored(gix_ignore::Kind::Expendable) => expendable += 1, + Status::Ignored(gix_ignore::Kind::Precious) => precious += 1, + Status::Untracked => untracked += 1, + } + } + + if matching_entries != 0 && matching_entries != entries { + return None; + } + + let dir_status = if opts.emit_untracked == CollapseDirectory + && untracked != 0 + && untracked + expendable + precious == entries + && (opts.for_deletion.is_none() + || (precious == 0 && expendable == 0) + || (precious == 0 && opts.emit_ignored.is_some())) + { + entry::Status::Untracked + } else if opts.emit_ignored == Some(CollapseDirectory) { + if expendable != 0 && expendable == entries { + entry::Status::Ignored(gix_ignore::Kind::Expendable) + } else if precious != 0 && precious == entries { + entry::Status::Ignored(gix_ignore::Kind::Precious) + } else { + return None; + } + } else { + return None; + }; + + if !matches!(dir_status, entry::Status::Untracked | entry::Status::Ignored(_)) { + return None; + } + + if !ctx.pathspec.directory_matches_prefix(dir_rela_path, false) { + return None; + } + + // Pathspecs affect the collapse of the next level, hence find the highest-value one. + let dir_pathspec_match = state.on_hold[self.start_index..] + .iter() + .filter_map(|e| e.pathspec_match) + .max() + .or_else(|| { + // Only take directory matches for value if they are above the 'guessed' ones. + // Otherwise we end up with seemingly matched entries in the parent directory which + // affects proper folding. + dir_info + .pathspec_match + .filter(|m| matches!(m, PathspecMatch::WildcardMatch | PathspecMatch::Verbatim)) + }); + 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 { + removed_without_emitting += 1; + }; + } + out.seen_entries += removed_without_emitting as u32; + + state.on_hold.push(Entry { + rela_path: dir_rela_path.to_owned(), + status: dir_status, + disk_kind: dir_info.disk_kind, + index_kind: dir_info.index_kind, + pathspec_match: dir_pathspec_match, + }); + Some(action) + } +} + +impl Options { + fn should_hold(&self, status: entry::Status) -> bool { + if status.is_pruned() { + return false; + } + self.emit_ignored == Some(CollapseDirectory) || self.emit_untracked == CollapseDirectory + } +} diff --git a/gix-dir/tests/dir.rs b/gix-dir/tests/dir.rs new file mode 100644 index 0000000000..81d7d83341 --- /dev/null +++ b/gix-dir/tests/dir.rs @@ -0,0 +1,4 @@ +pub use gix_testtools::Result; + +mod walk; +pub mod walk_utils; diff --git a/gix-dir/tests/dir_walk_cwd.rs b/gix-dir/tests/dir_walk_cwd.rs new file mode 100644 index 0000000000..afffaee423 --- /dev/null +++ b/gix-dir/tests/dir_walk_cwd.rs @@ -0,0 +1,33 @@ +use crate::walk_utils::{collect, entry, fixture, options}; +use gix_dir::entry::Kind::File; +use gix_dir::entry::Status::Untracked; +use gix_dir::walk; +use std::path::Path; + +pub mod walk_utils; + +#[test] +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) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 3, + } + ); + assert_eq!( + &entries, + &[ + entry("d/a", Untracked, File), + entry("d/b", Untracked, File), + entry("d/d/a", Untracked, File), + ] + ); + Ok(()) +} diff --git a/gix-dir/tests/fixtures/generated-archives/.gitignore b/gix-dir/tests/fixtures/generated-archives/.gitignore new file mode 100644 index 0000000000..cd3b9cb930 --- /dev/null +++ b/gix-dir/tests/fixtures/generated-archives/.gitignore @@ -0,0 +1,3 @@ +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-symlinks.sh b/gix-dir/tests/fixtures/many-symlinks.sh new file mode 100644 index 0000000000..f38abf74d1 --- /dev/null +++ b/gix-dir/tests/fixtures/many-symlinks.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -eu -o pipefail + +# Note that symlink creation fails on Windows for some reason, +# so these tests shouldn't be run there. + +git init breakout-symlink +(cd breakout-symlink + mkdir hide + ln -s ../.. hide/breakout + touch file +) + +ln -s breakout-symlink symlink-to-breakout-symlink + +git init immediate-breakout-symlink +(cd immediate-breakout-symlink + ln -s .. breakout +) diff --git a/gix-dir/tests/fixtures/many.sh b/gix-dir/tests/fixtures/many.sh new file mode 100644 index 0000000000..859947b579 --- /dev/null +++ b/gix-dir/tests/fixtures/many.sh @@ -0,0 +1,299 @@ +#!/bin/bash +set -eu -o pipefail + +# Nothing here may use symlinks so these fixtures can be used on windows as well. + +git init with-nested-dot-git +(cd with-nested-dot-git + mkdir -p dir/.git/subdir + touch dir/.git/config dir/.git/subdir/bar +) + +git init with-nested-capitalized-dot-git +(cd with-nested-capitalized-dot-git + mkdir -p dir/.GIT/subdir + touch dir/.GIT/config dir/.GIT/subdir/bar +) + +git init dir-with-file +(cd dir-with-file + mkdir dir + touch dir/file +) + +git init dir-with-tracked-file +(cd dir-with-tracked-file + mkdir dir + touch dir/file + git add . + git commit -m "init" +) + +git init ignored-dir +(cd ignored-dir + mkdir dir + touch dir/file + echo "dir/" > .gitignore +) + +cp -R ignored-dir ignored-dir-with-nested-repository +(cd ignored-dir-with-nested-repository + echo "*.o" >> .gitignore + git add . + mkdir dir/subdir objs + (cd dir/subdir + touch a + git init nested + ) + >objs/a.o +) + +cp -R ignored-dir ignored-dir-with-nested-bare-repository +(cd ignored-dir-with-nested-bare-repository + mkdir dir/subdir + (cd dir/subdir + git init --bare nested-bare + ) + git init --bare bare +) + +mkdir untracked-hidden-bare +(cd untracked-hidden-bare + mkdir subdir + git init --bare subdir/hidden-bare + >subdir/file +) + +git init tracked-is-ignored +(cd tracked-is-ignored + mkdir dir + touch dir/file + echo "dir/" > .gitignore + git add --force . && git commit -m "init" +) + +git init nested-repository +(cd nested-repository + touch file + git add . && git commit -m "init" + + git init nested + (cd nested + touch file + git add file && git commit -m "init" + ) +) + +git clone dir-with-tracked-file with-submodule +(cd with-submodule + git submodule add ../dir-with-tracked-file submodule + git commit -m "submodule added" +) + +git init nonstandard-worktree +(cd nonstandard-worktree + mkdir dir-with-dot-git + mv .git dir-with-dot-git + + git -C dir-with-dot-git config core.worktree "$PWD" + touch dir-with-dot-git/inside + touch seemingly-outside + git -C dir-with-dot-git add inside ../seemingly-outside + git -C dir-with-dot-git commit -m "init" +) + +git init nonstandard-worktree-untracked +(cd nonstandard-worktree-untracked + mkdir dir-with-dot-git + mv .git dir-with-dot-git + + git -C dir-with-dot-git config core.worktree "$PWD" + touch dir-with-dot-git/inside + touch seemingly-outside + git -C dir-with-dot-git add inside ../seemingly-outside + git -C dir-with-dot-git commit -m "init" + + rm dir-with-dot-git/.git/index +) + +git init partial-checkout-cone-mode +(cd partial-checkout-cone-mode + touch a b + mkdir c1 + (cd c1 && touch a b && mkdir c2 && cd c2 && touch a b) + (cd c1 && mkdir c3 && cd c3 && touch a b) + mkdir d + (cd d && touch a b && mkdir c4 && cd c4 && touch a b c5) + + git add . + git commit -m "init" + + git sparse-checkout set c1/c2 --sparse-index + + mkdir d && touch d/file-created-manually +) + +git init partial-checkout-non-cone +(cd partial-checkout-non-cone + touch a b + mkdir c1 + (cd c1 && touch a b && mkdir c2 && cd c2 && touch a b) + (cd c1 && mkdir c3 && cd c3 && touch a b) + mkdir d + (cd d && touch a b && mkdir c4 && cd c4 && touch a b c5) + + git add . + git commit -m "init" + + git sparse-checkout set c1/c2 --no-cone + mkdir d && touch d/file-created-manually +) + +git init only-untracked +(cd only-untracked + >a + >b + mkdir d + >d/a + >d/b + mkdir d/d + >d/d/a + >c +) + +cp -R only-untracked subdir-untracked +(cd subdir-untracked + git add . + git rm --cached d/d/a + git commit -m "init" +) + +cp -R subdir-untracked subdir-untracked-and-ignored +(cd subdir-untracked-and-ignored + >a.o + >b.o + >d/a.o + >d/b.o + >d/d/a.o + >d/d/b.o + >c.o + mkdir generated d/generated d/d/generated + touch generated/a generated/a.o d/generated/b d/d/generated/b + mkdir -p objs/sub + touch objs/a.o objs/b.o objs/sub/other.o + + echo "*.o" > .gitignore + echo "generated/" >> .gitignore +) + +mkdir untracked-and-ignored-for-collapse +(cd untracked-and-ignored-for-collapse + echo "ignored/" >> .gitignore + echo "*.o" >> .gitignore + + mkdir untracked ignored mixed ignored-inside + touch untracked/a ignored/b mixed/c mixed/c.o ignored-inside/d.o +) + +git init untracked-and-precious +(cd untracked-and-precious + echo '*.o' >> .gitignore + echo '$*.precious' >> .gitignore + + mkdir -p d/d + touch d/a d/b && git add . + + touch a.o d/a.o d/b.o + touch d/d/new d/d/a.precious + + git commit -m "init" +) + +git init expendable-and-precious +(cd expendable-and-precious + echo "*.o" >> .gitignore + echo '$precious' >> .gitignore + echo '$/mixed/precious' >> .gitignore + echo '$/all-precious/' >> .gitignore + echo "/all-expendable/" >> .gitignore + echo '$*.precious' >> .gitignore + + git add .gitignore + + touch a.o + touch precious + mkdir mixed + touch mixed/precious mixed/b.o + + (mkdir some-expendable && cd some-expendable + touch file.o file new && git add file + ) + + (mkdir some-precious && cd some-precious + touch file.precious file new && git add file + ) + + mkdir all-precious all-expendable all-precious-by-filematch all-expendable-by-filematch + touch all-precious/a all-precious/b all-expendable/c all-expendable/d + (cd all-precious-by-filematch + touch a.precious b.precious + ) + (cd all-expendable-by-filematch + touch e.o f.o + ) + + git commit -m "init" +) + +mkdir empty-and-untracked-dir +(cd empty-and-untracked-dir + mkdir empty untracked + >untracked/file +) + + +mkdir complex-empty +(cd complex-empty + mkdir empty-toplevel + mkdir -p only-dirs/sub/subsub only-dirs/other + mkdir -p dirs-and-files/sub dirs-and-files/dir + touch dirs-and-files/dir/file +) + +git init type-mismatch +(cd type-mismatch + mkdir dir-is-file && >dir-is-file/a + >file-is-dir + git add . + rm -Rf dir-is-file + >dir-is-file + rm file-is-dir && mkdir file-is-dir && >file-is-dir/b +) + +git init type-mismatch-icase +(cd type-mismatch-icase + mkdir dir-is-file && >dir-is-file/a + >file-is-dir + git add . + rm -Rf dir-is-file + >Dir-is-File + rm file-is-dir && mkdir File-is-Dir && >File-is-Dir/b +) + +git init type-mismatch-icase-clash-dir-is-file +(cd type-mismatch-icase-clash-dir-is-file + empty_oid=$(git hash-object -w --stdin d +) + +cp -R type-mismatch-icase-clash-dir-is-file type-mismatch-icase-clash-file-is-dir +(cd type-mismatch-icase-clash-file-is-dir + rm d + mkdir D && >D/a +) +mkdir empty +touch just-a-file diff --git a/gix-dir/tests/walk/mod.rs b/gix-dir/tests/walk/mod.rs new file mode 100644 index 0000000000..fa6d9a7a2f --- /dev/null +++ b/gix-dir/tests/walk/mod.rs @@ -0,0 +1,2736 @@ +use gix_dir::walk; +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, +}; +use gix_dir::entry::Kind::*; +use gix_dir::entry::PathspecMatch::*; +use gix_dir::entry::Status::*; +use gix_dir::walk::EmissionMode::*; +use gix_dir::walk::ForDeletionMode; +use gix_ignore::Kind::*; + +#[test] +#[cfg_attr(windows, ignore = "symlinks the way they are organized don't yet work on windows")] +fn root_may_not_lead_through_symlinks() -> crate::Result { + for (name, intermediate, expected) in [ + ("immediate-breakout-symlink", "", 0), + ("breakout-symlink", "hide", 1), + ("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) + }) + .unwrap_err(); + assert!( + matches!(err, walk::Error::SymlinkInRoot { component_index, .. } if component_index == expected), + "{name} should have component {expected}" + ); + } + Ok(()) +} + +#[test] +fn empty_root() -> crate::Result { + let root = fixture("empty"); + let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep)); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 1, + returned_entries: entries.len(), + seen_entries: 1, + } + ); + assert_eq!( + entries.len(), + 0, + "by default, nothing is shown as the directory is empty" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_empty_directories: true, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 1, + returned_entries: entries.len(), + seen_entries: 1, + } + ); + assert_eq!(entries.len(), 1); + assert_eq!( + &entries[0], + &entry("", Untracked, EmptyDirectory), + "this is how we can indicate the worktree is entirely untracked" + ); + Ok(()) +} + +#[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)); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 9, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + assert_eq!( + entries, + &[ + entry("dirs-and-files/dir/file", Untracked, File), + entry("dirs-and-files/sub", Untracked, EmptyDirectory), + entry("empty-toplevel", Untracked, EmptyDirectory), + entry("only-dirs/other", Untracked, EmptyDirectory), + entry("only-dirs/sub/subsub", Untracked, EmptyDirectory), + ], + "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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_empty_directories: false, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 9, + returned_entries: entries.len(), + seen_entries: 5, + } + ); + assert_eq!( + entries, + &[entry("dirs-and-files/dir/file", Untracked, File),], + "by default, no empty directory shows up" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_empty_directories: true, + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 9, + returned_entries: entries.len(), + seen_entries: 9, + } + ); + assert_eq!( + entries, + &[ + entry("dirs-and-files", Untracked, Directory), + entry("empty-toplevel", Untracked, EmptyDirectory), + entry("only-dirs", Untracked, Directory), + ], + "empty directories collapse just fine" + ); + Ok(()) +} + +#[test] +fn only_untracked() -> crate::Result { + let root = fixture("only-untracked"); + let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep)); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7, + } + ); + 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/a", Untracked, File), + ] + ); + + let (out, entries) = collect_filtered(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep), Some("d/*")); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7, + } + ); + assert_eq!( + &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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7 + 2, + }, + "There are 2 extra directories that we fold into, but ultimately discard" + ); + assert_eq!( + &entries, + &[ + entry("a", Untracked, File), + entry("b", Untracked, File), + entry("c", Untracked, File), + entry("d", Untracked, Directory), + ] + ); + Ok(()) +} + +#[test] +fn only_untracked_explicit_pathspec_selection() -> crate::Result { + let root = fixture("only-untracked"); + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: Matching, + ..options() + }, + keep, + ) + }, + ["d/a", "d/d/a"], + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7, + }, + ); + assert_eq!( + &entries, + &[ + entryps("d/a", Untracked, File, Verbatim), + entryps("d/d/a", Untracked, File, Verbatim) + ], + "this works just like expected, as nothing is collapsed anyway" + ); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + emit_pruned: true, + ..options() + }, + keep, + ) + }, + ["d/a", "d/d/a"], + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7, + }, + "no collapsing happens" + ); + assert_eq!( + &entries, + &[ + entry_nokind(".git", DotGit), + entry_nokind("a", Pruned), + entry_nokind("b", Pruned), + entry_nokind("c", Pruned), + entryps("d/a", Untracked, File, Verbatim), + entry_nokind("d/b", Pruned), + entryps("d/d/a", Untracked, File, Verbatim)], + "we actually want to mention the entries that matched the pathspec precisely, so two of them would be needed here \ + while preventing the directory collapse from happening" + ); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + Some("d/*"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7 + 2, + }, + "collapsing happens just like Git" + ); + assert_eq!( + &entries, + &[entryps("d", Untracked, Directory, WildcardMatch)], + "wildcard matches allow collapsing directories because Git does" + ); + Ok(()) +} + +#[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)); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 6, + returned_entries: entries.len(), + seen_entries: 18, + } + ); + assert_eq!( + &entries, + &[ + entry_nokind(".git", DotGit), + entry(".gitignore", Tracked, File), + entry("a.o", Ignored(Expendable), File), + entry("all-expendable", Ignored(Expendable), Directory), + entry("all-expendable-by-filematch/e.o", Ignored(Expendable), File), + entry("all-expendable-by-filematch/f.o", Ignored(Expendable), File), + entry("all-precious", Ignored(Precious), Directory), + entry("all-precious-by-filematch/a.precious", Ignored(Precious), File), + entry("all-precious-by-filematch/b.precious", Ignored(Precious), File), + entry("mixed/b.o", Ignored(Expendable), File), + entry("mixed/precious", Ignored(Precious), File), + entry("precious", Ignored(Precious), File), + entry("some-expendable/file", Tracked, File), + entry("some-expendable/file.o", Ignored(Expendable), File), + entry("some-expendable/new", Untracked, File), + entry("some-precious/file", Tracked, File), + entry("some-precious/file.precious", Ignored(Precious), File), + entry("some-precious/new", Untracked, File), + ], + "listing everything is a 'matching' preset, which is among the most efficient." + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_tracked: true, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 6, + returned_entries: entries.len(), + seen_entries: 18 + 2, + } + ); + + assert_eq!( + &entries, + &[ + entry(".gitignore", Tracked, File), + entry("a.o", Ignored(Expendable), File), + entry("all-expendable", Ignored(Expendable), Directory), + entry("all-expendable-by-filematch", Ignored(Expendable), Directory), + entry("all-precious", Ignored(Precious), Directory), + entry("all-precious-by-filematch", Ignored(Precious), Directory), + entry("mixed/b.o", Ignored(Expendable), File), + entry("mixed/precious", Ignored(Precious), File), + entry("precious", Ignored(Precious), File), + entry("some-expendable/file", Tracked, File), + entry("some-expendable/file.o", Ignored(Expendable), File), + entry("some-expendable/new", Untracked, File), + entry("some-precious/file", Tracked, File), + entry("some-precious/file.precious", Ignored(Precious), File), + entry("some-precious/new", Untracked, File), + ], + "those that have tracked and ignored won't be collapsed, nor will be folders that have mixed precious and ignored files,\ + those with all files of one type will be collapsed though" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: None, + emit_untracked: CollapseDirectory, + emit_tracked: false, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 6, + returned_entries: entries.len(), + seen_entries: 16 + 2, + } + ); + + assert_eq!( + &entries, + &[ + entry("some-expendable/new", Untracked, File), + entry("some-precious/new", Untracked, File), + ], + "even with collapsing, once there is a tracked file in the directory, we show the untracked file directly" + ); +} + +#[test] +fn subdir_untracked() -> crate::Result { + let root = fixture("subdir-untracked"); + let (out, entries) = collect(&root, |keep, ctx| walk(&root, &root, ctx, options(), keep)); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7, + } + ); + assert_eq!(&entries, &[entry("d/d/a", Untracked, File)]); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| walk(&root, &root, ctx, options(), keep), + Some("d/d/*"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7, + }, + "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)]); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 7 + 1, + }, + "there is a folded directory we added" + ); + 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) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 1, + returned_entries: entries.len(), + seen_entries: 1, + } + ); + assert_eq!( + &entries, + &[entry("d/d/a", Untracked, File)], + "even from subdirs, paths are worktree relative" + ); + Ok(()) +} + +#[test] +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( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + for_deletion, + ..options() + }, + keep, + ) + }, + Some("d/d/generated/b"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 19, + }, + ); + assert_eq!( + &entries, + &[entryps("d/d/generated/b", Ignored(Expendable), File, Verbatim)], + "pathspecs allow reaching into otherwise ignored directories, ignoring the flag to collapse" + ); + } + Ok(()) +} + +#[test] +fn untracked_and_ignored_for_deletion_negative_spec() -> crate::Result { + let root = fixture("subdir-untracked-and-ignored"); + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + emit_pruned: true, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some(":!*generated*"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 23, + }, + ); + assert_eq!( + &entries, + &[ + entry_nokind(".git", DotGit), + entry(".gitignore", Untracked, File), + entry("a.o", Ignored(Expendable), File), + entry("b.o", Ignored(Expendable), File), + entry("c.o", Ignored(Expendable), File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d/a", Untracked, File), + entry("d/d/a.o", Ignored(Expendable), File), + entry("d/d/b.o", Ignored(Expendable), File), + entryps("d/d/generated", Ignored(Expendable), Directory, Excluded), + entryps("d/generated", Ignored(Expendable), Directory, Excluded), + entryps("generated", Ignored(Expendable), Directory, Excluded), + entry("objs", Ignored(Expendable), Directory), + ], + "'generated' folders are excluded, and collapsing is done where possible. \ + Note that Git wants to incorrectly delete `d/d` as it doesn't see the excluded \ + ignored file inside, which would incorrectly delete something the users didn't want deleted." + ); + Ok(()) +} + +#[test] +fn untracked_and_ignored() -> crate::Result { + let root = fixture("subdir-untracked-and-ignored"); + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(Matching), + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 21, + }, + "some untracked ones are hidden by default" + ); + assert_eq!( + &entries, + &[ + entry(".gitignore", Untracked, File), + entry("a.o", Ignored(Expendable), File), + entry("b.o", Ignored(Expendable), File), + entry("c.o", Ignored(Expendable), File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d/a", Untracked, File), + entry("d/d/a.o", Ignored(Expendable), File), + entry("d/d/b.o", Ignored(Expendable), File), + entry("d/d/generated", Ignored(Expendable), Directory), + entry("d/generated", Ignored(Expendable), Directory), + entry("generated", Ignored(Expendable), Directory), + entry("objs/a.o", Ignored(Expendable), File), + entry("objs/b.o", Ignored(Expendable), File), + entry("objs/sub/other.o", Ignored(Expendable), File), + ] + ); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_pruned: true, + ..options() + }, + keep, + ) + }, + Some("**/a*"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 21, + }, + "basically the same result…" + ); + + assert_eq!( + &entries, + &[ + entry_nokind(".git", DotGit), + entry_nomatch(".gitignore", Pruned, File), + entryps("d/d/a", Untracked, File, WildcardMatch), + ], + "…but with different classification as the ignore file is pruned so it's not untracked anymore" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: None, + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 21 + 1, + }, + "we still encounter the same amount of entries, and 1 folded directory" + ); + assert_eq!( + &entries, + &[entry(".gitignore", Untracked, File), entry("d/d", Untracked, Directory)], + "aggregation kicks in here" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 21 + 2, + }, + "some untracked ones are hidden by default, folded directories" + ); + assert_eq!( + &entries, + &[ + entry(".gitignore", Untracked, File), + entry("a.o", Ignored(Expendable), File), + entry("b.o", Ignored(Expendable), File), + entry("c.o", Ignored(Expendable), File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d/a", Untracked, File), + entry("d/d/a.o", Ignored(Expendable), File), + entry("d/d/b.o", Ignored(Expendable), File), + entry("d/d/generated", Ignored(Expendable), Directory), + entry("d/generated", Ignored(Expendable), Directory), + entry("generated", Ignored(Expendable), Directory), + entry("objs", Ignored(Expendable), Directory), + ], + "objects are aggregated" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 21 + 3, + }, + "some untracked ones are hidden by default, and folded directories" + ); + assert_eq!( + &entries, + &[ + entry(".gitignore", Untracked, File), + entry("a.o", Ignored(Expendable), File), + entry("b.o", Ignored(Expendable), File), + entry("c.o", Ignored(Expendable), File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d", Untracked, Directory), + entryps_dirstat("d/d/a.o", Ignored(Expendable), File, Always, Untracked), + entryps_dirstat("d/d/b.o", Ignored(Expendable), File, Always, Untracked), + entryps_dirstat("d/d/generated", Ignored(Expendable), Directory, Always, Untracked), + entry("d/generated", Ignored(Expendable), Directory), + entry("generated", Ignored(Expendable), Directory), + entry("objs", Ignored(Expendable), Directory), + ], + "ignored ones are aggregated, and we get the same effect as with `git status --ignored` - collapsing of untracked happens\ + and we still list the ignored files that were inside.\ + Also note the entries that would be dropped in case of `git clean` are marked with `entry_dirstat`, which would display what's\ + done differently." + ); + Ok(()) +} + +#[test] +fn untracked_and_ignored_collapse_handling_mixed() -> crate::Result { + let root = fixture("subdir-untracked-and-ignored"); + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: None, + ..options() + }, + keep, + ) + }, + Some("d/d/b.o"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 19, + }, + ); + + assert_eq!( + &entries, + &[entryps("d/d/b.o", Ignored(Expendable), File, Verbatim)], + "when files are selected individually, they are never collapsed" + ); + + for (spec, pathspec_match) in [("d/d/*", WildcardMatch), ("d/d", Prefix), ("d/d/", Prefix)] { + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: None, + ..options() + }, + keep, + ) + }, + Some(spec), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 21, + }, + ); + + assert_eq!( + &entries, + &[ + entryps("d/d", Untracked, Directory, pathspec_match), + entryps_dirstat("d/d/a.o", Ignored(Expendable), File, pathspec_match, Untracked), + entryps_dirstat("d/d/b.o", Ignored(Expendable), File, pathspec_match, Untracked), + entryps_dirstat( + "d/d/generated", + Ignored(Expendable), + Directory, + pathspec_match, + Untracked + ), + ], + "with wildcard matches, it's OK to collapse though" + ); + } + Ok(()) +} + +#[test] +fn untracked_and_ignored_collapse_handling_for_deletion_with_wildcards() -> crate::Result { + let root = fixture("subdir-untracked-and-ignored"); + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some("*.o"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 8, + returned_entries: entries.len(), + seen_entries: 26 + }, + ); + assert_eq!( + &entries, + &[ + entryps("a.o", Ignored(Expendable), File, WildcardMatch), + entryps("b.o", Ignored(Expendable), File, WildcardMatch), + entryps("c.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/a.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/b.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/d/a.o", Ignored(Expendable), File, WildcardMatch,), + entryps("d/d/b.o", Ignored(Expendable), File, WildcardMatch,), + entryps("generated/a.o", Ignored(Expendable), File, WildcardMatch), + entryps("objs", Ignored(Expendable), Directory, WildcardMatch), + ], + "when using wildcards like these, we actually want to see only the suffixed items even if they all match, like Git does. \ + However, we have no way to differentiate `*` from `*.o`, in which case Git decides to delete the directory instead of its \ + contents, so it's not perfect there either. \ + 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( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some("*"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 8, + returned_entries: entries.len(), + seen_entries: 28 + }, + ); + assert_eq!( + &entries, + &[ + entryps(".gitignore", Untracked, File, WildcardMatch), + entryps("a.o", Ignored(Expendable), File, WildcardMatch), + entryps("b.o", Ignored(Expendable), File, WildcardMatch), + entryps("c.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/a.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/b.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/d", Untracked, Directory, WildcardMatch,), + entryps_dirstat("d/d/a.o", Ignored(Expendable), File, WildcardMatch, Untracked), + entryps_dirstat("d/d/b.o", Ignored(Expendable), File, WildcardMatch, Untracked), + entryps_dirstat( + "d/d/generated", + Ignored(Expendable), + Directory, + WildcardMatch, + Untracked + ), + entryps("d/generated", Ignored(Expendable), Directory, WildcardMatch), + entryps("generated", Ignored(Expendable), Directory, WildcardMatch), + entryps("objs", Ignored(Expendable), Directory, WildcardMatch), + ], + "In this case, Git is doing exactly the same" + ); + Ok(()) +} + +#[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( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some("generated/*.o"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 12, + }, + ); + assert_eq!( + &entries, + &[entryps("generated/a.o", Ignored(Expendable), File, WildcardMatch)], + "this is the same result as '*.o', but limited to a subdirectory" + ); + Ok(()) +} + +#[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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: None, + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 21, + }, + ); + + assert_eq!( + &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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 5, + returned_entries: entries.len(), + seen_entries: 24, + }, + ); + + assert_eq!( + &entries, + &[ + entry(".gitignore", Untracked, File), + entry("a.o", Ignored(Expendable), File), + entry("b.o", Ignored(Expendable), File), + entry("c.o", Ignored(Expendable), File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d", Untracked, Directory), + entryps_dirstat("d/d/a.o", Ignored(Expendable), File, Always, Untracked), + entryps_dirstat("d/d/b.o", Ignored(Expendable), File, Always, Untracked), + entryps_dirstat("d/d/generated", Ignored(Expendable), Directory, Always, Untracked), + entry("d/generated", Ignored(Expendable), Directory), + entry("generated", Ignored(Expendable), Directory), + entry("objs", Ignored(Expendable), Directory), + ], + "with ignored files, we can collapse untracked and ignored like before" + ); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some("d/d/*"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 21, + }, + ); + + 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 collapses" + ); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + emit_tracked: true, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some("d/d/*.o"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 20, + }, + ); + + assert_eq!( + &entries, + &[ + entryps("d/d/a.o", Ignored(Expendable), File, WildcardMatch), + entryps("d/d/b.o", Ignored(Expendable), File, WildcardMatch), + ], + "If the wildcard doesn't match everything, it can't be collapsed" + ); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some("d/d/"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 21, + }, + ); + + 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), + ], + "a prefix match works similarly, while also listing the dropped content for good measure" + ); + + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: None, + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }, + Some("d/d/"), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 19, + }, + ); + + assert_eq!( + &entries, + &[entryps("d/d/a", Untracked, File, Prefix)], + "a prefix match works similarly" + ); + Ok(()) +} + +#[test] +fn precious_are_not_expendable() { + let root = fixture("untracked-and-precious"); + let (_out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(Matching), + emit_untracked: Matching, + ..options_emit_all() + }, + keep, + ) + }); + assert_eq!( + &entries, + &[ + entry_nokind(".git", DotGit), + entry(".gitignore", Tracked, File), + entry("a.o", Ignored(Expendable), File), + entry("d/a", Tracked, File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b", Tracked, File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d/a.precious", Ignored(Precious), File), + entry("d/d/new", Untracked, File), + ], + "just to have an overview" + ); + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 10, + }, + ); + + assert_eq!( + &entries, + &[ + entry("a.o", Ignored(Expendable), File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d", Untracked, Directory), + entryps_dirstat("d/d/a.precious", Ignored(Precious), File, Always, Untracked), + ], + "by default precious files are treated no differently than expendable files, which is fine\ + unless you want to delete `d/d`. Then we shouldn't ever see `d/d` and have to deal with \ + a collapsed precious file." + ); + + for (equivalent_pathspec, expected_match) in [("d/*", WildcardMatch), ("d/", Prefix), ("d", Prefix)] { + let (out, entries) = collect_filtered( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + Some(equivalent_pathspec), + ); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 10, + }, + "{equivalent_pathspec}: should yield same result" + ); + + assert_eq!( + &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), + entryps_dirstat("d/d/a.precious", Ignored(Precious), File, expected_match, Untracked), + ], + "'{equivalent_pathspec}' should yield the same entries - note how collapsed directories inherit the pathspec" + ); + } + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(Default::default()), + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 9, + }, + ); + + assert_eq!( + &entries, + &[ + entry("a.o", Ignored(Expendable), File), + entry("d/a.o", Ignored(Expendable), File), + entry("d/b.o", Ignored(Expendable), File), + entry("d/d/a.precious", Ignored(Precious), File), + entryps("d/d/new", Untracked, File, Always), + ], + "If collapses are for deletion, we don't treat precious files like expendable/ignored anymore so they show up individually \ + and prevent collapsing into a folder in the first place" + ); +} + +#[test] +#[cfg_attr( + not(target_vendor = "apple"), + ignore = "Needs filesystem that folds unicode composition" +)] +fn decomposed_unicode_in_directory_is_returned_precomposed() -> crate::Result { + let root = gix_testtools::tempfile::TempDir::new()?; + + let decomposed = "a\u{308}"; + let precomposed = "ä"; + 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| { + walk( + root.path(), + root.path(), + ctx, + walk::Options { + precompose_unicode: true, + ..options() + }, + keep, + ) + }); + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 1, + } + ); + assert_eq!(entries.len(), 1); + assert_eq!( + &entries[0], + &entry(format!("{precomposed}/{precomposed}").as_str(), 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); + assert_eq!( + &entries[0], + &entry(format!("{decomposed}/{decomposed}").as_str(), Untracked, File), + "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)); + 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], + &entry("file", Untracked, File), + "it allows symlinks for the worktree itself" + ); + Ok(()) +} + +#[test] +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, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 0, + returned_entries: entries.len(), + 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)); + } + Ok(()) +} + +#[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( + &root, + |keep, ctx| { + walk( + &root.join("dir-with-dot-git").join("inside"), + &root, + ctx, + walk::Options { + emit_tracked: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options::git_dir("dir-with-dot-git/.git"), + )?; + + 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], + &entry("dir-with-dot-git/inside", Tracked, File), + "everything is tracked, so it won't try to detect git repositories anyway" + ); + + let (out, entries) = try_collect_filtered_opts( + &root, + |keep, ctx| { + walk( + &root.join("dir-with-dot-git").join("inside"), + &root, + ctx, + walk::Options { + emit_tracked: false, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options::git_dir("dir-with-dot-git/.git"), + )?; + + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 0, + returned_entries: 0, + seen_entries: 1, + } + ); + + assert!(entries.is_empty()); + Ok(()) +} + +#[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( + &root, + |keep, ctx| { + walk( + &root.join("dir-with-dot-git").join("inside"), + &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), + "it can enter a dir and treat it as normal even if own .git is inside,\ + which otherwise would be a repository" + ); + Ok(()) +} + +#[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); + assert_eq!( + &entries[0], + &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)); + 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], + &entry("nested", Untracked, Repository), + "thus it ends in the directory that is a repository" + ); + Ok(()) +} + +#[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, + ) + }); + 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" + ); + Ok(()) +} + +#[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)); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 4, + } + ); + assert_eq!( + entries, + [ + entry_nokind(".git", DotGit), + entry(".gitmodules", Tracked, File), + entry("dir/file", Tracked, File), + entry("submodule", Tracked, Repository) + ], + "thus it ends in the directory that is the submodule" + ); + Ok(()) +} + +#[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) + }); + 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], + &entry("dir/file", Tracked, File), + "a tracked file as root just returns that file (even though no iteration is possible)" + ); + Ok(()) +} + +#[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) + }); + 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], + &entry("dir/file", Untracked, File), + "an untracked file as root just returns that file (even though no iteration is possible)" + ); + Ok(()) +} + +#[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(); + 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( + &root, + |keep, ctx| walk(&root.join("dir"), &root, ctx, options_emit_all(), keep), + Some("no-match/"), + ); + 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], + &entry_nomatch("dir", Pruned, Directory), + "the pathspec didn't match the root, early abort" + ); + Ok(()) +} + +#[test] +fn file_root_is_shown_if_pathspec_matches_exactly() -> crate::Result { + let root = fixture("dir-with-file"); + let (out, entries) = collect_filtered( + &root, + |keep, ctx| walk(&root.join("dir").join("file"), &root, ctx, options(), keep), + Some("*dir/*"), + ); + 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), + "the pathspec matched the root precisely" + ); + Ok(()) +} + +#[test] +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) + }); + 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], + &entry(walk_root, Tracked, File), + "tracking is checked first, so we can safe exclude checks for most entries" + ); + Ok(()) +} + +#[test] +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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(emission), + emit_tracked: true, + emit_untracked: emission, + ..options_emit_all() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 3, + } + ); + assert_eq!(entries.len(), 3); + + assert_eq!( + entries, + [ + entry_nokind(".git", DotGit), + entry(".gitignore", Tracked, File), + entry("dir/file", Tracked, File) + ], + "'tracked' is the overriding property here, so we even enter ignored directories if they have tracked contents,\ + otherwise we might permanently miss new untracked files in there. Emission mode has no effect" + ); + } + + Ok(()) +} + +#[test] +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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: Matching, + for_deletion, + emit_empty_directories: true, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 2, + } + ); + + assert_eq!( + entries, + [ + entry("empty", Untracked, EmptyDirectory), + entry("untracked/file", Untracked, File) + ], + "we find all untracked entries, no matter the deletion mode" + ); + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + emit_empty_directories: true, + for_deletion, + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 3, + returned_entries: entries.len(), + seen_entries: 3, + } + ); + + assert_eq!( + entries, + [ + entry("empty", Untracked, EmptyDirectory), + entry("untracked", Untracked, Directory) + ], + "we find all untracked directories, no matter the deletion mode" + ); + } + Ok(()) +} + +#[test] +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"] { + 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, + ) + }); + 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], + &entry("dir", Ignored(Expendable), Directory), + "excluded directories or files that walkdir are listed without further recursion" + ); + } + } + Ok(()) +} + +#[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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + for_deletion: Some(Default::default()), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + // NOTE: do not use `_out` as `.git` directory contents can change, it's controlled by Git, causing flakiness. + + assert_eq!( + entries, + [ + entry(".gitignore", Untracked, File), + entry("bare", Untracked, Directory), + entry("dir", Ignored(Expendable), Directory), + ], + "by default, only the directory is listed and recursion is stopped there, as it matches the ignore directives. \ + 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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + for_deletion: Some(ForDeletionMode::FindNonBareRepositoriesInIgnoredDirectories), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + + assert_eq!( + entries, + [ + entry(".gitignore", Untracked, File), + entry("bare", Untracked, Directory), + entry("dir", Ignored(Expendable), Directory), + ], + "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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + for_deletion: Some(ForDeletionMode::FindRepositoriesInIgnoredDirectories), + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + + assert_eq!( + entries, + [ + entry(".gitignore", Untracked, File), + entry("bare", Untracked, Directory), + entry("dir/file", Ignored(Expendable), File), + entry("dir/subdir/nested-bare", Ignored(Expendable), Repository), + ], + "Only in this mode we are able to find them, but it's expensive" + ); + Ok(()) +} + +#[test] +fn nested_repos_in_untracked_directories() -> crate::Result { + let root = fixture("untracked-hidden-bare"); + let (_out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }); + // NOTE: do not use `_out` as `.git` directory contents can change, it's controlled by Git, causing flakiness. + + assert_eq!( + entries, + [entry("subdir", Untracked, Directory)], + "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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_untracked: CollapseDirectory, + classify_untracked_bare_repositories: true, + ..options() + }, + keep, + ) + }); + + assert_eq!( + entries, + [ + entry("subdir/file", Untracked, File), + entry("subdir/hidden-bare", Untracked, Repository) + ], + "With this flag we are able to find the bare repository" + ); + + Ok(()) +} + +#[test] +fn nested_repos_in_ignored_directories() -> crate::Result { + let root = fixture("ignored-dir-with-nested-repository"); + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(Matching), + 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: 4, + } + ); + + assert_eq!( + entries, + [ + entry("dir", Ignored(Expendable), Directory), + entry("objs/a.o", Ignored(Expendable), File), + ], + "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| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(Matching), + emit_untracked: CollapseDirectory, + for_deletion: Some(ForDeletionMode::FindNonBareRepositoriesInIgnoredDirectories), + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 6, + } + ); + + assert_eq!( + entries, + [ + entry("dir/file", Ignored(Expendable), File), + entry("dir/subdir/a", Ignored(Expendable), File), + entry("dir/subdir/nested", Ignored(Expendable), Repository), + entry("objs/a.o", Ignored(Expendable), File) + ], + "in this mode, we will list repositories nested in ignored directories separately" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: CollapseDirectory, + for_deletion: Some(ForDeletionMode::FindNonBareRepositoriesInIgnoredDirectories), + ..options() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 7, + } + ); + + assert_eq!( + entries, + [ + entry("dir/file", Ignored(Expendable), File), + entry("dir/subdir/a", Ignored(Expendable), File), + entry("dir/subdir/nested", Ignored(Expendable), Repository), + entry("objs", Ignored(Expendable), Directory), + ], + "finally, we can't fold if there are any nested repositories. Note how the folding isn't affected in unrelated directories" + ); + Ok(()) +} + +#[test] +#[cfg_attr( + not(target_vendor = "apple"), + ignore = "Needs filesystem that folds unicode composition" +)] +fn decomposed_unicode_in_root_is_returned_precomposed() -> crate::Result { + let root = gix_testtools::tempfile::TempDir::new()?; + + let decomposed = "a\u{308}"; + 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, + ) + }); + + 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], + &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); + assert_eq!( + &entries[0], + &entry(decomposed, Untracked, File), + "if disabled, it stays decomposed as provided" + ); + Ok(()) +} + +#[test] +fn untracked_and_ignored_collapse_mix() { + let root = fixture("untracked-and-ignored-for-collapse"); + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(CollapseDirectory), + emit_untracked: Matching, + ..options_emit_all() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 4, + returned_entries: entries.len(), + seen_entries: 7, + } + ); + assert_eq!( + entries, + [ + entry(".gitignore", Untracked, File), + entry("ignored", Ignored(Expendable), Directory), + entry("ignored-inside", Ignored(Expendable), Directory), + entry("mixed/c", Untracked, File), + entry("mixed/c.o", Ignored(Expendable), File), + entry("untracked/a", Untracked, File), + ], + "ignored collapses separately from untracked" + ); + + let (out, entries) = collect(&root, |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_ignored: Some(Matching), + emit_untracked: CollapseDirectory, + ..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.o", Ignored(Expendable), File, Untracked), + entry("untracked", Untracked, Directory), + ], + "untracked collapses separately from ignored, but note that matching directories are still emitted, i.e. ignored/" + ); +} + +#[test] +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 (out, entries) = collect(&root, |keep, ctx| { + walk( + &root.join("dir").join(".GIT").join(dir), + &root, + ctx, + walk::Options { + ignore_case: true, + ..options_emit_all() + }, + keep, + ) + }); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 0, + returned_entries: entries.len(), + 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" + ); + } + + 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,); + assert_eq!( + &entries[0], + &entry("dir/.GIT/config", Untracked, File), + "it passes right through what now seems like any other directory" + ); +} + +#[test] +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, + ) + }); + 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], + &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." + ); + } + Ok(()) +} + +#[test] +fn type_mismatch() { + let root = fixture("type-mismatch"); + let (out, entries) = try_collect_filtered_opts( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_tracked: true, + emit_untracked: Matching, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + ) + .expect("success"); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 3, + } + ); + assert_eq!(entries.len(), 2); + + assert_eq!( + entries, + [ + entry("dir-is-file", Untracked, File).with_index_kind(Directory), + entry("file-is-dir/b", Untracked, File) + ], + "as long as the index doesn't claim otherwise (i.e. uptodate) it will handle these changes correctly. \ + Also, `dir-is-file` is tracked as directory, but not as file.\ + The typechange is visible only when there is an entry in the index, of course" + ); + + let (out, entries) = try_collect_filtered_opts( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_tracked: true, + emit_untracked: CollapseDirectory, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + ) + .expect("success"); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 3 + 1, + } + ); + assert_eq!(entries.len(), 2); + + assert_eq!( + entries, + [ + entry("dir-is-file", Untracked, File).with_index_kind(Directory), + entry("file-is-dir", Untracked, Directory).with_index_kind(File) + ], + "collapsing works as well, and we allow to see the typechange" + ); +} + +#[test] +fn type_mismatch_ignore_case() { + let root = fixture("type-mismatch-icase"); + let (out, entries) = try_collect_filtered_opts( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_tracked: true, + emit_untracked: Matching, + ignore_case: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + ) + .expect("success"); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 3, + } + ); + assert_eq!( + entries, + [ + entry("Dir-is-File", Untracked, File).with_index_kind(Directory), + entry("File-is-Dir/b", Untracked, File) + ], + "this is the same as in the non-icase version, which means that icase lookup works" + ); + + let (out, entries) = try_collect_filtered_opts( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_tracked: true, + emit_untracked: CollapseDirectory, + ignore_case: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + ) + .expect("success"); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 3 + 1, + } + ); + assert_eq!( + entries, + [ + entry("Dir-is-File", Untracked, File).with_index_kind(Directory), + entry("File-is-Dir", Untracked, Directory).with_index_kind(File) + ], + "this is the same as in the non-icase version, which means that icase lookup works" + ); +} + +#[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( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_tracked: true, + emit_untracked: Matching, + ignore_case: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + ) + .expect("success"); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 1, + returned_entries: entries.len(), + seen_entries: 2, + } + ); + assert_eq!( + entries, + [entry("d", Tracked, File)], + "file `d` exists on disk and it is found as well. This is just because we prefer finding files over dirs, coincidence" + ); +} + +#[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( + &root, + |keep, ctx| { + walk( + &root, + &root, + ctx, + walk::Options { + emit_tracked: true, + emit_untracked: CollapseDirectory, + ignore_case: true, + ..options() + }, + keep, + ) + }, + None::<&str>, + Options { + fresh_index: false, + ..Default::default() + }, + ) + .expect("success"); + assert_eq!( + out, + walk::Outcome { + read_dir_calls: 2, + returned_entries: entries.len(), + seen_entries: 2, + } + ); + assert_eq!( + entries, + [entry("D/a", Tracked, File)], + "`D` exists on disk as directory, and we manage to to find it in in the index, hence no collapsing happens.\ + If there was no special handling for this, it would have found the file (`d` in the index, icase), which would have been wrong." + ); +} diff --git a/gix-dir/tests/walk_utils/mod.rs b/gix-dir/tests/walk_utils/mod.rs new file mode 100644 index 0000000000..3f7fdde339 --- /dev/null +++ b/gix-dir/tests/walk_utils/mod.rs @@ -0,0 +1,274 @@ +use bstr::BStr; +use gix_dir::{entry, walk, Entry}; +use gix_testtools::scripted_fixture_read_only; +use std::path::{Path, PathBuf}; + +pub fn fixture_in(filename: &str, name: &str) -> PathBuf { + let root = scripted_fixture_read_only(format!("{filename}.sh")).expect("script works"); + root.join(name) +} + +pub fn fixture(name: &str) -> PathBuf { + fixture_in("many", name) +} + +/// Default options +pub fn options() -> walk::Options { + walk::Options::default() +} + +/// Default options +pub fn options_emit_all() -> walk::Options { + walk::Options { + precompose_unicode: false, + ignore_case: false, + recurse_repositories: false, + for_deletion: None, + classify_untracked_bare_repositories: false, + emit_pruned: true, + emit_ignored: Some(walk::EmissionMode::Matching), + emit_tracked: true, + emit_untracked: walk::EmissionMode::Matching, + emit_empty_directories: true, + } +} + +pub fn entry( + rela_path: impl AsRef, + status: entry::Status, + disk_kind: entry::Kind, +) -> (Entry, Option) { + entryps(rela_path, status, disk_kind, entry::PathspecMatch::Always) +} + +pub fn entry_nomatch( + rela_path: impl AsRef, + status: entry::Status, + disk_kind: entry::Kind, +) -> (Entry, Option) { + ( + Entry { + rela_path: rela_path.as_ref().to_owned(), + status, + disk_kind: Some(disk_kind), + index_kind: index_kind_from_status(status, disk_kind), + pathspec_match: None, + }, + None, + ) +} + +pub fn entry_nokind(rela_path: impl AsRef, status: entry::Status) -> (Entry, Option) { + ( + Entry { + rela_path: rela_path.as_ref().to_owned(), + status, + disk_kind: None, + index_kind: None, + pathspec_match: None, + }, + None, + ) +} + +pub fn entryps( + rela_path: impl AsRef, + status: entry::Status, + disk_kind: entry::Kind, + pathspec_match: entry::PathspecMatch, +) -> (Entry, Option) { + ( + Entry { + rela_path: rela_path.as_ref().to_owned(), + status, + disk_kind: Some(disk_kind), + index_kind: index_kind_from_status(status, disk_kind), + pathspec_match: Some(pathspec_match), + }, + None, + ) +} + +pub fn entry_dirstat( + rela_path: impl AsRef, + status: entry::Status, + disk_kind: entry::Kind, + dir_status: entry::Status, +) -> (Entry, Option) { + ( + Entry { + rela_path: rela_path.as_ref().to_owned(), + status, + disk_kind: Some(disk_kind), + index_kind: index_kind_from_status(status, disk_kind), + pathspec_match: Some(entry::PathspecMatch::Always), + }, + Some(dir_status), + ) +} + +/// These are entries that have been collapsed into a single directory. +pub fn entryps_dirstat( + rela_path: impl AsRef, + status: entry::Status, + disk_kind: entry::Kind, + pathspec_match: entry::PathspecMatch, + dir_status: entry::Status, +) -> (Entry, Option) { + ( + Entry { + rela_path: rela_path.as_ref().to_owned(), + status, + disk_kind: Some(disk_kind), + index_kind: index_kind_from_status(status, disk_kind), + pathspec_match: Some(pathspec_match), + }, + Some(dir_status), + ) +} + +fn index_kind_from_status(status: entry::Status, disk_kind: entry::Kind) -> Option { + matches!(status, entry::Status::Tracked | entry::Status::TrackedExcluded).then_some(disk_kind) +} + +pub trait EntryExt { + fn with_index_kind(self, index_kind: entry::Kind) -> Self; +} + +impl EntryExt for (Entry, Option) { + fn with_index_kind(mut self, index_kind: entry::Kind) -> Self { + self.0.index_kind = index_kind.into(); + self + } +} + +pub fn collect( + worktree_root: &Path, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, +) -> (walk::Outcome, Entries) { + try_collect(worktree_root, cb).unwrap() +} + +pub fn collect_filtered( + worktree_root: &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() +} + +pub fn try_collect( + worktree_root: &Path, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, +) -> Result<(walk::Outcome, Entries), walk::Error> { + try_collect_filtered(worktree_root, cb, None::<&str>) +} + +pub fn try_collect_filtered( + worktree_root: &Path, + 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()) +} + +pub fn try_collect_filtered_opts( + worktree_root: &Path, + cb: impl FnOnce(&mut dyn walk::Delegate, walk::Context) -> Result, + patterns: impl IntoIterator>, + Options { fresh_index, git_dir }: Options<'_>, +) -> Result<(walk::Outcome, Entries), 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), + |bytes| { + gix_index::State::from_bytes( + &bytes, + std::time::UNIX_EPOCH.into(), + gix_index::hash::Kind::Sha1, + Default::default(), + ) + .map(|t| t.0) + .expect("valid index") + }, + ); + if fresh_index { + index + .entries_mut() + .iter_mut() + .filter(|e| { + // relevant for partial checkouts, all related entries will have skip-worktree set, + // which also means they will never be up-to-date. + !e.flags.contains(gix_index::entry::Flags::SKIP_WORKTREE) + }) + .for_each(|e| { + // pretend that the index was refreshed beforehand so we know what's uptodate. + e.flags |= gix_index::entry::Flags::UPTODATE; + }); + } + let mut search = gix_pathspec::Search::from_specs( + patterns.into_iter().map(|spec| { + gix_pathspec::parse(spec.as_ref(), gix_pathspec::Defaults::default()).expect("tests use valid pattern") + }), + None, + "we don't provide absolute pathspecs, thus need no worktree root".as_ref(), + ) + .expect("search creation can't fail"); + let mut stack = gix_worktree::Stack::from_state_and_ignore_case( + worktree_root, + false, /* ignore case */ + gix_worktree::stack::State::IgnoreStack(gix_worktree::stack::state::Ignore::new( + Default::default(), + Default::default(), + None, + gix_worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, + )), + &index, + index.path_backing(), + ); + + 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, + walk::Context { + git_dir_realpath: &git_dir_realpath, + current_dir: &cwd, + index: &index, + ignore_case_index_lookup: Some(&lookup), + pathspec: &mut search, + pathspec_attributes: &mut |_, _, _, _| panic!("we do not use pathspecs that require attributes access."), + excludes: Some(&mut stack), + objects: &gix_object::find::Never, + }, + )?; + + Ok((outcome, dlg.into_entries_by_path())) +} + +pub struct Options<'a> { + pub fresh_index: bool, + pub git_dir: Option<&'a str>, +} + +impl<'a> Options<'a> { + pub fn git_dir(dir: &'a str) -> Self { + Options { + git_dir: Some(dir), + ..Default::default() + } + } +} + +impl<'a> Default for Options<'a> { + fn default() -> Self { + Options { + fresh_index: true, + git_dir: None, + } + } +} + +type Entries = Vec<(Entry, Option)>; From d8bd45eb4dba4aca2ef009b1594f244c669625b8 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 5 Feb 2024 17:18:29 +0100 Subject: [PATCH 11/15] feat: add `open::Options::current_dir()`. That way it's possible to obtain the current working directory with which the repository was opened. --- gix/src/repository/location.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gix/src/repository/location.rs b/gix/src/repository/location.rs index 8ee907ca9c..0c23b458d7 100644 --- a/gix/src/repository/location.rs +++ b/gix/src/repository/location.rs @@ -15,6 +15,17 @@ impl crate::Repository { self.options.git_dir_trust.expect("definitely set by now") } + /// Return the current working directory as present during the instantiation of this repository. + /// + /// Note that this should be preferred over manually obtaining it as this may have been adjusted to + /// deal with `core.precomposeUnicode`. + pub fn current_dir(&self) -> &Path { + self.options + .current_dir + .as_deref() + .expect("BUG: cwd is always set after instantiation") + } + /// Returns the main git repository if this is a repository on a linked work-tree, or the `git_dir` itself. pub fn common_dir(&self) -> &std::path::Path { self.common_dir.as_deref().unwrap_or_else(|| self.git_dir()) From 6914d1a7195d869ea776f30bbf29edb300f460be Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 11 Feb 2024 10:15:43 +0100 Subject: [PATCH 12/15] feat: add `Repository::dirwalk_with_delegate()`. That way it's possible to perform arbitrary directory walks, useful for status, clean, and add. --- Cargo.lock | 1 + gix/Cargo.toml | 6 +- gix/src/dirwalk.rs | 109 ++++++++++++++++++++++++++++++++++ gix/src/lib.rs | 5 ++ gix/src/repository/dirwalk.rs | 94 +++++++++++++++++++++++++++++ gix/src/repository/mod.rs | 3 + gix/tests/repository/mod.rs | 30 ++++++++++ 7 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 gix/src/dirwalk.rs create mode 100644 gix/src/repository/dirwalk.rs diff --git a/Cargo.lock b/Cargo.lock index 2a604b0b8d..8d163345b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1343,6 +1343,7 @@ dependencies = [ "gix-credentials", "gix-date 0.8.3", "gix-diff", + "gix-dir", "gix-discover 0.30.0", "gix-features 0.38.0", "gix-filter", diff --git a/gix/Cargo.toml b/gix/Cargo.toml index 732c7eac64..984c80418e 100644 --- a/gix/Cargo.toml +++ b/gix/Cargo.toml @@ -51,7 +51,7 @@ default = ["max-performance-safe", "comfort", "basic", "extras"] basic = ["blob-diff", "revision", "index"] ## Various additional features and capabilities that are not necessarily part of what most users would need. -extras = ["worktree-stream", "worktree-archive", "revparse-regex", "mailmap", "excludes", "attributes", "worktree-mutation", "credentials", "interrupt", "status"] +extras = ["worktree-stream", "worktree-archive", "revparse-regex", "mailmap", "excludes", "attributes", "worktree-mutation", "credentials", "interrupt", "status", "dirwalk"] ## Various progress-related features that improve the look of progress message units. comfort = ["gix-features/progress-unit-bytes", "gix-features/progress-unit-human-numbers"] @@ -73,6 +73,9 @@ interrupt = ["dep:signal-hook", "gix-tempfile/signals"] ## Access to `.git/index` files. index = ["dep:gix-index"] +## Support directory walks with Git-style annoations. +dirwalk = ["dep:gix-dir"] + ## Access to credential helpers, which provide credentials for URLs. # Note that `gix-negotiate` just piggibacks here, as 'credentials' is equivalent to 'fetch & push' right now. credentials = ["dep:gix-credentials", "dep:gix-prompt", "dep:gix-negotiate"] @@ -251,6 +254,7 @@ gix-sec = { version = "^0.10.4", path = "../gix-sec" } gix-date = { version = "^0.8.3", path = "../gix-date" } gix-refspec = { version = "^0.22.0", path = "../gix-refspec" } gix-filter = { version = "^0.9.0", path = "../gix-filter", optional = true } +gix-dir = { version = "^0.1.0", path = "../gix-dir", optional = true } gix-config = { version = "^0.35.0", path = "../gix-config" } gix-odb = { version = "^0.58.0", path = "../gix-odb" } diff --git a/gix/src/dirwalk.rs b/gix/src/dirwalk.rs new file mode 100644 index 0000000000..693ddc19fc --- /dev/null +++ b/gix/src/dirwalk.rs @@ -0,0 +1,109 @@ +use gix_dir::walk::{EmissionMode, ForDeletionMode}; + +/// Options for use in the [`Repository::dirwalk()`](crate::Repository::dirwalk()) function. +/// +/// Note that all values start out disabled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct Options { + precompose_unicode: bool, + ignore_case: bool, + + recurse_repositories: bool, + emit_pruned: bool, + emit_ignored: Option, + for_deletion: Option, + emit_tracked: bool, + emit_untracked: EmissionMode, + emit_empty_directories: bool, + classify_untracked_bare_repositories: bool, +} + +/// Construction +impl Options { + pub(crate) fn from_fs_caps(caps: gix_fs::Capabilities) -> Self { + Self { + precompose_unicode: caps.precompose_unicode, + ignore_case: caps.ignore_case, + recurse_repositories: false, + emit_pruned: false, + emit_ignored: None, + for_deletion: None, + emit_tracked: false, + emit_untracked: Default::default(), + emit_empty_directories: false, + classify_untracked_bare_repositories: false, + } + } +} + +impl From for gix_dir::walk::Options { + fn from(v: Options) -> Self { + gix_dir::walk::Options { + precompose_unicode: v.precompose_unicode, + ignore_case: v.ignore_case, + recurse_repositories: v.recurse_repositories, + emit_pruned: v.emit_pruned, + emit_ignored: v.emit_ignored, + for_deletion: v.for_deletion, + emit_tracked: v.emit_tracked, + emit_untracked: v.emit_untracked, + emit_empty_directories: v.emit_empty_directories, + classify_untracked_bare_repositories: v.classify_untracked_bare_repositories, + } + } +} + +impl Options { + /// 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 { + self.recurse_repositories = toggle; + self + } + /// If `toggle` is `true`, entries that are pruned and whose [Kind](gix_dir::entry::Kind) is known will be emitted. + pub fn emit_pruned(mut self, toggle: bool) -> Self { + self.emit_pruned = toggle; + self + } + /// If `value` is `Some(mode)`, entries that are ignored will be emitted according to the given `mode`. + /// If `None`, ignored entries will not be emitted at all. + pub fn emit_ignored(mut self, value: Option) -> Self { + self.emit_ignored = value; + self + } + /// When the walk is for deletion, `value` must be `Some(_)` to assure we don't collapse directories that have precious files in + /// them, and otherwise assure that no entries are observable that shouldn't be deleted. + /// If `None`, precious files are treated like expendable files, which is usually what you want when displaying them + /// for addition to the repository, and the collapse of folders can be more generous in relation to ignored files. + pub fn for_deletion(mut self, value: Option) -> Self { + self.for_deletion = value; + self + } + /// If `toggle` is `true`, we will also emit entries for tracked items. Otherwise these will remain 'hidden', + /// even if a pathspec directly refers to it. + pub fn emit_tracked(mut self, toggle: bool) -> Self { + self.emit_tracked = toggle; + self + } + /// Controls the way untracked files are emitted. By default, this is happening immediately and without any simplification. + pub fn emit_untracked(mut self, toggle: EmissionMode) -> Self { + self.emit_untracked = toggle; + self + } + /// If `toggle` is `true`, emit empty directories as well. Note that a directory also counts as empty if it has any + /// amount or depth of nested 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 fn emit_empty_directories(mut self, toggle: bool) -> Self { + self.emit_empty_directories = toggle; + self + } + + /// If `toggle` is `true`, we will not only find non-bare repositories in untracked directories, but also bare ones. + /// + /// Note that this is very costly, but without it, bare repositories will appear like untracked directories when collapsed, + /// and they will be recursed into. + pub fn classify_untracked_bare_repositories(mut self, toggle: bool) -> Self { + self.classify_untracked_bare_repositories = toggle; + self + } +} diff --git a/gix/src/lib.rs b/gix/src/lib.rs index c3df3094de..8c2124270e 100644 --- a/gix/src/lib.rs +++ b/gix/src/lib.rs @@ -101,6 +101,8 @@ pub use gix_commitgraph as commitgraph; #[cfg(feature = "credentials")] pub use gix_credentials as credentials; pub use gix_date as date; +#[cfg(feature = "dirwalk")] +pub use gix_dir as dir; pub use gix_features as features; use gix_features::threading::OwnShared; pub use gix_features::{ @@ -174,6 +176,9 @@ pub use types::{Pathspec, PathspecDetached, Submodule}; /// pub mod clone; pub mod commit; +#[cfg(feature = "dirwalk")] +/// +pub mod dirwalk; pub mod head; pub mod id; pub mod object; diff --git a/gix/src/repository/dirwalk.rs b/gix/src/repository/dirwalk.rs new file mode 100644 index 0000000000..6d47247f6b --- /dev/null +++ b/gix/src/repository/dirwalk.rs @@ -0,0 +1,94 @@ +use crate::bstr::BStr; +use crate::{config, dirwalk, Repository}; +use std::path::Path; + +/// The error returned by [dirwalk()](Repository::dirwalk()). +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + Walk(#[from] gix_dir::walk::Error), + #[error("A working tree is required to perform a directory walk")] + MissinWorkDir, + #[error(transparent)] + Excludes(#[from] config::exclude_stack::Error), + #[error(transparent)] + Pathspec(#[from] crate::pathspec::init::Error), + #[error(transparent)] + Prefix(#[from] gix_path::realpath::Error), + #[error(transparent)] + FilesystemOptions(#[from] config::boolean::Error), +} + +impl Repository { + /// Return default options suitable for performing a directory walk on this repository. + /// + /// Used in conjunction with [`dirwalk()`](Self::dirwalk()) + pub fn dirwalk_options(&self) -> Result { + Ok(dirwalk::Options::from_fs_caps(self.filesystem_options()?)) + } + + /// Perform a directory walk configured with `options` under control of the `delegate`. Use `patterns` to + /// further filter entries. + /// + /// The `index` is used to determine if entries are tracked, and for excludes and attributes + /// lookup. Note that items will only count as tracked if they have the [`gix_index::entry::Flags::UPTODATE`] + /// flag set. + /// + /// See [`gix_dir::walk::delegate::Collect`] for a delegate that collects all seen entries. + pub fn dirwalk( + &self, + index: &gix_index::State, + patterns: impl IntoIterator>, + options: dirwalk::Options, + delegate: &mut dyn gix_dir::walk::Delegate, + ) -> Result { + let workdir = self.work_dir().ok_or(Error::MissinWorkDir)?; + let mut excludes = self + .excludes( + index, + None, + crate::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, + )? + .detach(); + let (mut pathspec, mut maybe_attributes) = self + .pathspec( + patterns, + true, /* inherit ignore case */ + index, + crate::worktree::stack::state::attributes::Source::WorktreeThenIdMapping, + )? + .into_parts(); + + let 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(), + current_dir: self.current_dir(), + index, + ignore_case_index_lookup: accelerate_lookup.as_ref(), + pathspec: &mut pathspec, + pathspec_attributes: &mut |relative_path, case, is_dir, out| { + let stack = maybe_attributes + .as_mut() + .expect("can only be called if attributes are used in patterns"); + stack + .set_case(case) + .at_entry(relative_path, Some(is_dir), &self.objects) + .map_or(false, |platform| platform.matching_attributes(out)) + }, + excludes: Some(&mut excludes), + objects: &self.objects, + }, + options.into(), + delegate, + ) + .map_err(Into::into) + } +} diff --git a/gix/src/repository/mod.rs b/gix/src/repository/mod.rs index d66edcf991..b60a4bc870 100644 --- a/gix/src/repository/mod.rs +++ b/gix/src/repository/mod.rs @@ -43,6 +43,9 @@ mod config; #[cfg(feature = "blob-diff")] pub mod diff; /// +#[cfg(feature = "dirwalk")] +pub mod dirwalk; +/// #[cfg(feature = "attributes")] pub mod filter; mod graph; diff --git a/gix/tests/repository/mod.rs b/gix/tests/repository/mod.rs index 6c36949997..9d11348007 100644 --- a/gix/tests/repository/mod.rs +++ b/gix/tests/repository/mod.rs @@ -15,6 +15,36 @@ mod state; mod submodule; mod worktree; +#[cfg(feature = "dirwalk")] +mod dirwalk { + use gix_dir::entry::Kind::*; + use gix_dir::walk::EmissionMode; + + #[test] + fn basics() -> crate::Result { + let repo = crate::named_repo("make_basic_repo.sh")?; + let untracked_only = repo.dirwalk_options()?.emit_untracked(EmissionMode::CollapseDirectory); + let mut collect = gix::dir::walk::delegate::Collect::default(); + let index = repo.index()?; + repo.dirwalk(&index, None::<&str>, untracked_only, &mut collect)?; + assert_eq!( + collect + .into_entries_by_path() + .into_iter() + .map(|e| (e.0.rela_path.to_string(), e.0.disk_kind.expect("kind is known"))) + .collect::>(), + [ + ("bare-repo-with-index.git".to_string(), Directory), + ("bare.git".into(), Directory), + ("non-bare-repo-without-index".into(), Repository), + ("some".into(), Directory) + ], + "note how bare repos are just directories by default" + ); + Ok(()) + } +} + #[test] fn size_in_memory() { let actual_size = std::mem::size_of::(); From ae86a6a206074b85ff1eba32aea9c8b40c087b17 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 11 Feb 2024 16:03:55 +0100 Subject: [PATCH 13/15] Adjust gitignore files with precious declarations --- .gitignore | 6 ++++++ gix-attributes/fuzz/.gitignore | 4 ++++ gix-commitgraph/fuzz/.gitignore | 5 +++++ gix-config-value/fuzz/.gitignore | 4 ++++ gix-config/fuzz/.gitignore | 5 ++++- gix-date/fuzz/.gitignore | 4 ++++ gix-object/fuzz/.gitignore | 4 ++++ gix-pathspec/fuzz/.gitignore | 4 ++++ gix-ref/fuzz/.gitignore | 4 ++++ gix-refspec/fuzz/.gitignore | 4 ++++ gix-revision/fuzz/.gitignore | 4 ++++ gix-url/fuzz/.gitignore | 4 ++++ 12 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f58e1dbfe3..33cedd75f8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,15 @@ target/ # repositories used for local testing /tests/fixtures/repos +$/tests/fixtures/repos/ + /tests/fixtures/commit-graphs/ +$/tests/fixtures/commit-graphs/ **/generated-do-not-edit/ # Cargo lock files of fuzz targets - let's have the latest versions of everything under test **/fuzz/Cargo.lock + +# newer Git sees these as precious, older Git falls through to the pattern above +$**/fuzz/Cargo.lock diff --git a/gix-attributes/fuzz/.gitignore b/gix-attributes/fuzz/.gitignore index 1a45eee776..8b8c18cc86 100644 --- a/gix-attributes/fuzz/.gitignore +++ b/gix-attributes/fuzz/.gitignore @@ -2,3 +2,7 @@ target corpus artifacts coverage + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-commitgraph/fuzz/.gitignore b/gix-commitgraph/fuzz/.gitignore index 1a45eee776..f2b7c9ef42 100644 --- a/gix-commitgraph/fuzz/.gitignore +++ b/gix-commitgraph/fuzz/.gitignore @@ -2,3 +2,8 @@ target corpus artifacts coverage + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus + diff --git a/gix-config-value/fuzz/.gitignore b/gix-config-value/fuzz/.gitignore index 1a45eee776..8b8c18cc86 100644 --- a/gix-config-value/fuzz/.gitignore +++ b/gix-config-value/fuzz/.gitignore @@ -2,3 +2,7 @@ target corpus artifacts coverage + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-config/fuzz/.gitignore b/gix-config/fuzz/.gitignore index bf9c8cb74d..ca62a98476 100644 --- a/gix-config/fuzz/.gitignore +++ b/gix-config/fuzz/.gitignore @@ -2,4 +2,7 @@ target corpus artifacts -Cargo.lock + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-date/fuzz/.gitignore b/gix-date/fuzz/.gitignore index ab0eaa1a49..0620a0b534 100644 --- a/gix-date/fuzz/.gitignore +++ b/gix-date/fuzz/.gitignore @@ -3,3 +3,7 @@ corpus/ artifacts/ coverage/ Cargo.lock + +# These usually involve a lot of local CPU time, keep them. +$artifacts/ +$corpus/ diff --git a/gix-object/fuzz/.gitignore b/gix-object/fuzz/.gitignore index 1a45eee776..8b8c18cc86 100644 --- a/gix-object/fuzz/.gitignore +++ b/gix-object/fuzz/.gitignore @@ -2,3 +2,7 @@ target corpus artifacts coverage + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-pathspec/fuzz/.gitignore b/gix-pathspec/fuzz/.gitignore index a0925114d6..94e6cc5313 100644 --- a/gix-pathspec/fuzz/.gitignore +++ b/gix-pathspec/fuzz/.gitignore @@ -1,3 +1,7 @@ target corpus artifacts + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-ref/fuzz/.gitignore b/gix-ref/fuzz/.gitignore index 1a45eee776..8b8c18cc86 100644 --- a/gix-ref/fuzz/.gitignore +++ b/gix-ref/fuzz/.gitignore @@ -2,3 +2,7 @@ target corpus artifacts coverage + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-refspec/fuzz/.gitignore b/gix-refspec/fuzz/.gitignore index a0925114d6..94e6cc5313 100644 --- a/gix-refspec/fuzz/.gitignore +++ b/gix-refspec/fuzz/.gitignore @@ -1,3 +1,7 @@ target corpus artifacts + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-revision/fuzz/.gitignore b/gix-revision/fuzz/.gitignore index a0925114d6..94e6cc5313 100644 --- a/gix-revision/fuzz/.gitignore +++ b/gix-revision/fuzz/.gitignore @@ -1,3 +1,7 @@ target corpus artifacts + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus diff --git a/gix-url/fuzz/.gitignore b/gix-url/fuzz/.gitignore index a0925114d6..94e6cc5313 100644 --- a/gix-url/fuzz/.gitignore +++ b/gix-url/fuzz/.gitignore @@ -1,3 +1,7 @@ target corpus artifacts + +# These usually involve a lot of local CPU time, keep them. +$artifacts +$corpus From e8597f3559187fc8add294e72eb33403cdff0e09 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Mon, 5 Feb 2024 15:02:27 +0100 Subject: [PATCH 14/15] feat: basic `gix clean` --- Cargo.toml | 5 +- gitoxide-core/Cargo.toml | 3 + gitoxide-core/src/repository/clean.rs | 294 ++++++++++++++++++++++++++ gitoxide-core/src/repository/mod.rs | 4 + src/plumbing/main.rs | 38 ++++ src/plumbing/options/mod.rs | 55 +++++ 6 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 gitoxide-core/src/repository/clean.rs diff --git a/Cargo.toml b/Cargo.toml index d676d0f8ac..5d767ba204 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,7 +129,7 @@ prodash-render-line = ["prodash/render-line", "prodash-render-line-crossterm", " cache-efficiency-debug = ["gix-features/cache-efficiency-debug"] ## A way to enable most `gitoxide-core` tools found in `ein tools`, namely `organize` and `estimate hours`. -gitoxide-core-tools = ["gitoxide-core/organize", "gitoxide-core/estimate-hours", "gitoxide-core-tools-archive"] +gitoxide-core-tools = ["gitoxide-core/organize", "gitoxide-core/estimate-hours", "gitoxide-core-tools-archive", "gitoxide-core-tools-clean"] ## A program to perform analytics on a `git` repository, using an auto-maintained sqlite database gitoxide-core-tools-query = ["gitoxide-core/query"] @@ -140,6 +140,9 @@ gitoxide-core-tools-corpus = ["gitoxide-core/corpus"] ## A sub-command to generate archive from virtual worktree checkouts. gitoxide-core-tools-archive = ["gitoxide-core/archive"] +## A sub-command to clean the worktree from untracked and ignored files. +gitoxide-core-tools-clean = ["gitoxide-core/clean"] + #! ### Building Blocks for mutually exclusive networking #! Blocking and async features are mutually exclusive and cause a compile-time error. This also means that `cargo … --all-features` will fail. #! Within each section, features can be combined. diff --git a/gitoxide-core/Cargo.toml b/gitoxide-core/Cargo.toml index 3ec6a0b734..fbffc7c546 100644 --- a/gitoxide-core/Cargo.toml +++ b/gitoxide-core/Cargo.toml @@ -28,6 +28,9 @@ corpus = [ "dep:rusqlite", "dep:sysinfo", "organize", "dep:crossbeam-channel", " ## The ability to create archives from virtual worktrees, similar to `git archive`. archive = ["dep:gix-archive-for-configuration-only", "gix/worktree-archive"] +## The ability to clean a repository, similar to `git clean`. +clean = [ "gix/dirwalk" ] + #! ### Mutually Exclusive Networking #! If both are set, _blocking-client_ will take precedence, allowing `--all-features` to be used. diff --git a/gitoxide-core/src/repository/clean.rs b/gitoxide-core/src/repository/clean.rs new file mode 100644 index 0000000000..5ee02b3052 --- /dev/null +++ b/gitoxide-core/src/repository/clean.rs @@ -0,0 +1,294 @@ +use crate::OutputFormat; + +#[derive(Default, Copy, Clone)] +pub enum FindRepository { + #[default] + NonBare, + All, +} + +pub struct Options { + pub debug: bool, + pub format: OutputFormat, + pub execute: bool, + pub ignored: bool, + pub precious: bool, + pub directories: bool, + pub repositories: bool, + pub skip_hidden_repositories: Option, + pub find_untracked_repositories: FindRepository, +} +pub(crate) mod function { + use crate::repository::clean::{FindRepository, Options}; + use crate::OutputFormat; + use anyhow::bail; + use gix::bstr::BString; + use gix::bstr::ByteSlice; + use gix::dir::entry::{Kind, Status}; + use gix::dir::walk::EmissionMode::CollapseDirectory; + use gix::dir::walk::ForDeletionMode::*; + use std::borrow::Cow; + + pub fn clean( + repo: gix::Repository, + out: &mut dyn std::io::Write, + err: &mut dyn std::io::Write, + patterns: Vec, + Options { + debug, + format, + execute, + ignored, + precious, + directories, + repositories, + skip_hidden_repositories, + find_untracked_repositories, + }: Options, + ) -> anyhow::Result<()> { + if format != OutputFormat::Human { + bail!("JSON output isn't implemented yet"); + } + let Some(workdir) = repo.work_dir() else { + bail!("Need a worktree to clean, this is a bare repository"); + }; + + let index = repo.index()?; + let has_patterns = !patterns.is_empty(); + let mut collect = gix::dir::walk::delegate::Collect::default(); + let collapse_directories = CollapseDirectory; + let options = repo + .dirwalk_options()? + .emit_pruned(true) + .for_deletion(if (ignored || precious) && directories { + match skip_hidden_repositories { + Some(FindRepository::NonBare) => Some(FindNonBareRepositoriesInIgnoredDirectories), + Some(FindRepository::All) => Some(FindRepositoriesInIgnoredDirectories), + None => None, + } + } else { + Some(IgnoredDirectoriesCanHideNestedRepositories) + }) + .classify_untracked_bare_repositories(matches!(find_untracked_repositories, FindRepository::All)) + .emit_untracked(collapse_directories) + .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_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 mut entries_to_clean = 0; + let mut skipped_directories = 0; + let mut skipped_ignored = 0; + let mut skipped_precious = 0; + let mut skipped_repositories = 0; + let mut pruned_entries = 0; + let mut saw_ignored_directory = false; + let mut saw_untracked_directory = false; + for (entry, dir_status) in entries.into_iter() { + if dir_status.is_some() { + if debug { + writeln!( + err, + "DBG: prune '{}' {:?} as parent dir is used instead", + entry.rela_path, entry.status + ) + .ok(); + } + continue; + } + + pruned_entries += usize::from(entry.pathspec_match.is_none()); + if entry.status.is_pruned() || entry.pathspec_match.is_none() { + continue; + } + let mut disk_kind = entry.disk_kind.expect("present if not pruned"); + match disk_kind { + Kind::File | Kind::Symlink => {} + Kind::EmptyDirectory | Kind::Directory | Kind::Repository => { + let keep = directories + || entry + .pathspec_match + .map_or(false, |m| m != gix::dir::entry::PathspecMatch::Always); + if !keep { + skipped_directories += 1; + if debug { + writeln!(err, "DBG: prune '{}' as -d is missing", entry.rela_path).ok(); + } + continue; + } + } + }; + + let keep = entry + .pathspec_match + .map_or(true, |m| m != gix::dir::entry::PathspecMatch::Excluded); + if !keep { + if debug { + writeln!(err, "DBG: prune '{}' as it is excluded by pathspec", entry.rela_path).ok(); + } + continue; + } + + let keep = match entry.status { + Status::DotGit | Status::Pruned | Status::TrackedExcluded => { + unreachable!("Pruned aren't emitted") + } + Status::Tracked => { + unreachable!("tracked aren't emitted") + } + Status::Ignored(gix::ignore::Kind::Expendable) => { + skipped_ignored += usize::from(!ignored); + ignored + } + Status::Ignored(gix::ignore::Kind::Precious) => { + skipped_precious += usize::from(!precious); + precious + } + Status::Untracked => true, + }; + if !keep { + if debug { + writeln!(err, "DBG: prune '{}' as -x or -p is missing", entry.rela_path).ok(); + } + continue; + } + + if disk_kind == gix::dir::entry::Kind::Directory + && gix::discover::is_git(&workdir.join(gix::path::from_bstr(entry.rela_path.as_bstr()))).is_ok() + { + if debug { + writeln!(err, "DBG: upgraded directory '{}' to repository", entry.rela_path).ok(); + } + disk_kind = gix::dir::entry::Kind::Repository; + } + + let is_ignored = matches!(entry.status, gix::dir::entry::Status::Ignored(_)); + let display_path = entry.rela_path[prefix_len..].as_bstr(); + if (!repositories || is_ignored) && disk_kind == gix::dir::entry::Kind::Repository { + if !is_ignored { + skipped_repositories += 1; + } + if debug { + writeln!(err, "DBG: skipped repository at '{display_path}'")?; + } + continue; + } + + if disk_kind == gix::dir::entry::Kind::Directory { + saw_ignored_directory |= is_ignored; + saw_untracked_directory |= entry.status == gix::dir::entry::Status::Untracked; + } + writeln!( + out, + "{maybe}{suffix} {}{} {status}", + display_path, + disk_kind.is_dir().then_some("/").unwrap_or_default(), + status = match entry.status { + Status::Ignored(kind) => { + Cow::Owned(format!( + "({})", + match kind { + gix::ignore::Kind::Precious => "$", + gix::ignore::Kind::Expendable => "❌", + } + )) + } + Status::Untracked => { + "".into() + } + status => + if debug { + format!("(DBG: {status:?})").into() + } else { + "".into() + }, + }, + maybe = if execute { "removing" } else { "WOULD remove" }, + suffix = if disk_kind == gix::dir::entry::Kind::Repository { + " repository" + } else { + "" + }, + )?; + + if execute { + let path = workdir.join(gix::path::from_bstr(entry.rela_path)); + if disk_kind.is_dir() { + std::fs::remove_dir_all(path)?; + } else { + std::fs::remove_file(path)?; + } + } else { + entries_to_clean += 1; + } + } + if !execute { + let mut messages = Vec::new(); + messages.extend( + (skipped_directories > 0).then(|| format!("Skipped {skipped_directories} directories - show with -d")), + ); + messages.extend( + (skipped_repositories > 0) + .then(|| format!("Skipped {skipped_repositories} repositories - show with -r")), + ); + messages.extend( + (skipped_ignored > 0).then(|| format!("Skipped {skipped_ignored} expendable entries - show with -x")), + ); + messages.extend( + (skipped_precious > 0).then(|| format!("Skipped {skipped_precious} precious entries - show with -p")), + ); + messages.extend( + (pruned_entries > 0 && has_patterns).then(|| { + format!("try to adjust your pathspec to reveal some of the {pruned_entries} pruned entries") + }), + ); + let make_msg = || -> String { + if messages.is_empty() { + return String::new(); + } + messages.join("; ") + }; + let wrap_in_parens = |msg: String| if msg.is_empty() { msg } else { format!(" ({msg})") }; + if entries_to_clean > 0 { + let mut wrote_nl = false; + let msg = make_msg(); + let mut msg = if msg.is_empty() { None } else { Some(msg) }; + if saw_ignored_directory && skip_hidden_repositories.is_none() { + writeln!(err).ok(); + wrote_nl = true; + writeln!( + err, + "WARNING: would remove repositories hidden inside ignored directories - use --skip-hidden-repositories to skip{}", + wrap_in_parens(msg.take().unwrap_or_default()) + )?; + } + if saw_untracked_directory && matches!(find_untracked_repositories, FindRepository::NonBare) { + if !wrote_nl { + writeln!(err).ok(); + wrote_nl = true; + } + writeln!( + err, + "WARNING: would remove repositories hidden inside untracked directories - use --find-untracked-repositories to find{}", + wrap_in_parens(msg.take().unwrap_or_default()) + )?; + } + if let Some(msg) = msg.take() { + if !wrote_nl { + writeln!(err).ok(); + } + writeln!(err, "{msg}").ok(); + } + } else { + writeln!(err, "Nothing to clean{}", wrap_in_parens(make_msg()))?; + } + } + Ok(()) + } +} diff --git a/gitoxide-core/src/repository/mod.rs b/gitoxide-core/src/repository/mod.rs index 49dbbef334..35d0c156a9 100644 --- a/gitoxide-core/src/repository/mod.rs +++ b/gitoxide-core/src/repository/mod.rs @@ -24,6 +24,10 @@ pub mod config; mod credential; pub use credential::function as credential; pub mod attributes; +#[cfg(feature = "clean")] +pub mod clean; +#[cfg(feature = "clean")] +pub use clean::function::clean; #[cfg(feature = "blocking-client")] pub mod clone; pub mod exclude; diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index f7d93648ab..b2aa0e9221 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -146,6 +146,44 @@ pub fn main() -> Result<()> { } match cmd { + #[cfg(feature = "gitoxide-core-tools-clean")] + Subcommands::Clean(crate::plumbing::options::clean::Command { + debug, + execute, + ignored, + precious, + directories, + pathspec, + repositories, + skip_hidden_repositories, + find_untracked_repositories, + }) => prepare_and_run( + "clean", + trace, + verbose, + progress, + progress_keep_open, + None, + move |_progress, out, err| { + core::repository::clean( + repository(Mode::Lenient)?, + out, + err, + pathspec, + core::repository::clean::Options { + debug, + format, + execute, + ignored, + precious, + directories, + repositories, + skip_hidden_repositories: skip_hidden_repositories.map(Into::into), + find_untracked_repositories: find_untracked_repositories.into(), + }, + ) + }, + ), Subcommands::Status(crate::plumbing::options::status::Platform { statistics, submodules, diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index e2aab13aa6..91844b330f 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -81,6 +81,8 @@ pub enum Subcommands { /// Subcommands for creating worktree archives #[cfg(feature = "gitoxide-core-tools-archive")] Archive(archive::Platform), + #[cfg(feature = "gitoxide-core-tools-clean")] + Clean(clean::Command), /// Subcommands for interacting with commit-graphs #[clap(subcommand)] CommitGraph(commitgraph::Subcommands), @@ -478,6 +480,59 @@ pub mod mailmap { } } +#[cfg(feature = "gitoxide-core-tools-clean")] +pub mod clean { + use gitoxide::shared::CheckPathSpec; + use gix::bstr::BString; + + #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] + pub enum FindRepository { + All, + #[default] + NonBare, + } + + impl From for gitoxide_core::repository::clean::FindRepository { + fn from(value: FindRepository) -> Self { + match value { + FindRepository::All => gitoxide_core::repository::clean::FindRepository::All, + FindRepository::NonBare => gitoxide_core::repository::clean::FindRepository::NonBare, + } + } + } + + #[derive(Debug, clap::Parser)] + pub struct Command { + /// Print additional debug information to help understand decisions it made. + #[arg(long)] + pub debug: bool, + /// Actually perform the operation, which deletes files on disk without chance of recovery. + #[arg(long, short = 'e')] + pub execute: bool, + /// Remove ignored (and expendable) files. + #[arg(long, short = 'x')] + pub ignored: bool, + /// Remove precious files. + #[arg(long, short = 'p')] + pub precious: bool, + /// Remove whole directories. + #[arg(long, short = 'd')] + pub directories: bool, + /// Remove nested repositories. + #[arg(long, short = 'r')] + pub repositories: bool, + /// Enter ignored directories to skip repositories contained within. + #[arg(long)] + pub skip_hidden_repositories: Option, + /// What kind of repositories to find inside of untracked directories. + #[arg(long, default_value = "non-bare")] + pub find_untracked_repositories: FindRepository, + /// The git path specifications to list attributes for, or unset to read from stdin one per line. + #[clap(value_parser = CheckPathSpec)] + pub pathspec: Vec, + } +} + pub mod odb { #[derive(Debug, clap::Subcommand)] pub enum Subcommands { From a3ab5bc24ff0eedfec5aca6df80649faf7a56d5f Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 11 Feb 2024 09:26:20 +0100 Subject: [PATCH 15/15] feat: `gix free index info` now lists EOIE and IEOT extensions. --- crate-status.md | 1 + gitoxide-core/src/index/information.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/crate-status.md b/crate-status.md index 0741fbc069..6a43168f4e 100644 --- a/crate-status.md +++ b/crate-status.md @@ -628,6 +628,7 @@ The git staging area. * [x] V2 - the default, including long-paths support * [x] V3 - extended flags * [x] V4 - delta-compression for paths + * [ ] TODO(perf): multi-threaded implementation should boost performance, spends most time in storing paths, has barely any benefit right now. * optional threading * [x] concurrent loading of index extensions * [x] threaded entry reading diff --git a/gitoxide-core/src/index/information.rs b/gitoxide-core/src/index/information.rs index 7d8b77799b..aa618ab10c 100644 --- a/gitoxide-core/src/index/information.rs +++ b/gitoxide-core/src/index/information.rs @@ -101,6 +101,12 @@ mod serde_only { if f.fs_monitor().is_some() { names.push("fs-monitor (FSMN)"); }; + if f.had_offset_table() { + names.push("offset-table (IEOT)") + } + if f.had_end_of_index_marker() { + names.push("end-of-index (EOIE)") + } Extensions { names, tree } }, entries: {