History

Merge branch 'pathspec-matching'

Sebastian Thiel committed 3 years ago

Commit 9f4dfe02 parents e83c38f c30ac0c56 files · +2367 −794Browse files

Unified · Split

56 files
  1. .gitattributes +1 −1
  2. Cargo.lock +2 −0
  3. crate-status.md +3 −2
  4. gitoxide-core/src/query/engine/command.rs +11 −5
  5. gitoxide-core/src/query/engine/mod.rs +1 −1
  6. gitoxide-core/src/repository/attributes/query.rs +23 −20
  7. gitoxide-core/src/repository/attributes/validate_baseline.rs +17 −22
  8. gitoxide-core/src/repository/exclude.rs +24 −22
  9. gitoxide-core/src/repository/index/entries.rs +148 −57
  10. gix-attributes/src/search/attributes.rs +9 −1
  11. gix-attributes/src/search/outcome.rs +43 −0
  12. gix-attributes/src/search/refmap.rs +3 −0
  13. gix-attributes/tests/search/mod.rs +23 −3
  14. gix-glob/src/parse.rs +11 −7
  15. gix-glob/src/pattern.rs +26 −8
  16. gix-glob/src/search/mod.rs +0 −3
  17. gix-glob/tests/parse/mod.rs +10 −0
  18. gix-glob/tests/pattern/matching.rs +28 −4
  19. gix-glob/tests/search/pattern.rs +0 −4
  20. gix-glob/tests/wildmatch/mod.rs +7 −1
  21. gix-ignore/src/search.rs +32 −33
  22. gix-index/src/access/mod.rs +28 −0
  23. gix-index/tests/index/access.rs +38 −1
  24. gix-path/src/convert.rs +1 −0
  25. gix-path/src/lib.rs +0 −7
  26. gix-path/src/spec.rs +0 −53
  27. gix-path/tests/convert/normalize.rs +1 −0
  28. gix-pathspec/Cargo.toml +1 −0
  29. gix-pathspec/fuzz/fuzz_targets/parse.rs +1 −1
  30. gix-pathspec/src/lib.rs +66 −9
  31. gix-pathspec/src/parse.rs +52 −8
  32. gix-pathspec/src/pattern.rs +192 −0
  33. gix-pathspec/src/search/init.rs +134 −0
  34. gix-pathspec/src/search/matching.rs +147 −0
  35. gix-pathspec/src/search/mod.rs +47 −0
  36. gix-pathspec/tests/fixtures/generated-archives/generate_pathspec_baseline.tar.xz +0 −3
  37. gix-pathspec/tests/fixtures/generated-archives/match_baseline.tar.xz +3 −0
  38. gix-pathspec/tests/fixtures/generated-archives/match_baseline_dirs.tar.xz +3 −0
  39. gix-pathspec/tests/fixtures/generated-archives/match_baseline_files.tar.xz +3 −0
  40. gix-pathspec/tests/fixtures/generated-archives/parse_baseline.tar.xz +3 −0
  41. gix-pathspec/tests/fixtures/match_baseline_dirs.sh +86 −0
  42. gix-pathspec/tests/fixtures/match_baseline_files.sh +94 −0
  43. gix-pathspec/tests/fixtures/parse_baseline.sh +13 −0
  44. gix-pathspec/tests/normalize/mod.rs +116 −0
  45. gix-pathspec/tests/parse/invalid.rs +152 −0
  46. gix-pathspec/tests/parse/mod.rs +93 −0
  47. gix-pathspec/tests/parse/valid.rs +329 −0
  48. gix-pathspec/tests/pathspec.rs +3 −471
  49. gix-pathspec/tests/search/mod.rs +257 −0
  50. gix/Cargo.toml +1 −0
  51. gix/src/lib.rs +1 −0
  52. gix/src/repository/location.rs +23 −20
  53. src/plumbing/main.rs +26 −22
  54. src/plumbing/options/mod.rs +20 −2
  55. src/porcelain/options.rs +1 −1
  56. src/shared.rs +10 −2
.gitattributes+1−1
@@ -1,5 +1,5 @@
11 **/generated-archives/*.tar.xz filter=lfs diff=lfs merge=lfs -text
22
33 # assure line feeds don't interfere with our working copy hash
4−**/tests/fixtures/*.sh text crlf=input eol=lf
4+**/tests/fixtures/**/*.sh text crlf=input eol=lf
55 /justfile text crlf=input eol=lf
Cargo.lock+2−0
@@ -1186,6 +1186,7 @@ dependencies = [ · 1185 unmodified lines
11861186 "gix-odb",
11871187 "gix-pack",
11881188 "gix-path 0.8.4",
1189+ "gix-pathspec",
11891190 "gix-prompt",
11901191 "gix-protocol",
11911192 "gix-ref 0.34.0",
@@ -1969,6 +1970,7 @@ dependencies = [ · 777 unmodified lines
19691970 "bstr",
19701971 "gix-attributes 0.16.0",
19711972 "gix-glob 0.10.2",
1973+ "gix-path 0.8.4",
19721974 "gix-testtools",
19731975 "once_cell",
19741976 "thiserror",
crate-status.md+3−2
@@ -361,8 +361,9 @@ Check out the [performance discussion][gix-traverse-performance] as well. · 360 unmodified lines
361361 * [ ] check for match
362362
363363 ### gix-pathspec
364−* [x] parse
365−* [ ] matching of paths
364+* [x] parse single
365+* [ ] parse file line by line (with or without quoting, NUL and LF/CRLF line separation) (see `--pathspec-from-file` and `--pathspec-file-nul`)
366+* [ ] matching of paths with git-attributes support
366367
367368 ### gix-refspec
368369 * [x] parse
gitoxide-core/src/query/engine/command.rs+11−5
@@ -1,6 +1,6 @@
11 use std::collections::HashMap;
22
3−use anyhow::Context;
3+use anyhow::{bail, Context};
44 use gix::{bstr::ByteSlice, prelude::ObjectIdExt, Progress};
55 use rusqlite::{params, OptionalExtension};
66
@@ -18,10 +18,16 @@ impl query::Engine { · 11 unmodified lines
1818 ) -> anyhow::Result<()> {
1919 match cmd {
2020 Command::TracePath { mut spec } => {
21− if let Some(prefix) = self.repo.prefix() {
22− spec.apply_prefix(&prefix?);
23− };
24− let relpath = spec.items().next().expect("spec has at least one item");
21+ let is_excluded = spec.is_excluded();
22+ let relpath = spec
23+ .normalize(
24+ self.repo.prefix()?.unwrap_or_default().as_ref(),
25+ self.repo.work_dir().unwrap_or_else(|| self.repo.git_dir()),
26+ )?
27+ .path();
28+ if relpath.is_empty() || is_excluded {
29+ bail!("Invalid pathspec {spec} - path must not be empty, not be excluded, and wildcards are taken literally")
30+ }
2531 let file_id: usize = self
2632 .con
2733 .query_row(
gitoxide-core/src/query/engine/mod.rs+1−1
@@ -1,7 +1,7 @@
11 pub enum Command {
22 TracePath {
33 /// The repo-relative path to the file to trace
4− spec: gix::path::Spec,
4+ spec: gix::pathspec::Pattern,
55 },
66 }
77
gitoxide-core/src/repository/attributes/query.rs+23−20
@@ -18,7 +18,7 @@ pub(crate) mod function { · 17 unmodified lines
1818
1919 pub fn query(
2020 repo: gix::Repository,
21− pathspecs: impl Iterator<Item = gix::path::Spec>,
21+ pathspecs: impl Iterator<Item = gix::pathspec::Pattern>,
2222 mut out: impl io::Write,
2323 mut err: impl io::Write,
2424 Options { format, statistics }: Options,
@@ -28,28 +28,31 @@ pub(crate) mod function { · 3 unmodified lines
2828 }
2929
3030 let mut cache = attributes_cache(&repo)?;
31− let prefix = repo.prefix().expect("worktree - we have an index by now")?;
3231 let mut matches = cache.attribute_matches();
32+ // TODO(pathspec): The search is just used as a shortcut to normalization, but one day should be used for an actual search.
33+ let search = gix::pathspec::Search::from_specs(
34+ pathspecs,
35+ repo.prefix()?.as_deref(),
36+ repo.work_dir().unwrap_or_else(|| repo.git_dir()),
37+ )?;
3338
34− for mut spec in pathspecs {
35− for path in spec.apply_prefix(&prefix).items() {
36− let is_dir = gix::path::from_bstr(path).metadata().ok().map(|m| m.is_dir());
37− let entry = cache.at_entry(path, is_dir, |oid, buf| repo.objects.find_blob(oid, buf))?;
39+ for spec in search.into_patterns() {
40+ let is_dir = gix::path::from_bstr(spec.path()).metadata().ok().map(|m| m.is_dir());
41+ let entry = cache.at_entry(spec.path(), is_dir, |oid, buf| repo.objects.find_blob(oid, buf))?;
3842
39− if !entry.matching_attributes(&mut matches) {
40− continue;
41− }
42− for m in matches.iter() {
43− writeln!(
44− out,
45− "{}:{}:{}\t{}\t{}",
46− m.location.source.map(Path::to_string_lossy).unwrap_or_default(),
47− m.location.sequence_number,
48− m.pattern,
49− path,
50− m.assignment
51− )?;
52− }
43+ if !entry.matching_attributes(&mut matches) {
44+ continue;
45+ }
46+ for m in matches.iter() {
47+ writeln!(
48+ out,
49+ "{}:{}:{}\t{}\t{}",
50+ m.location.source.map(Path::to_string_lossy).unwrap_or_default(),
51+ m.location.sequence_number,
52+ m.pattern,
53+ spec.path(),
54+ m.assignment
55+ )?;
5356 }
5457 }
5558
gitoxide-core/src/repository/attributes/validate_baseline.rs+17−22
@@ -18,6 +18,7 @@ pub(crate) mod function { · 17 unmodified lines
1818 };
1919
2020 use anyhow::{anyhow, bail};
21+ use gix::bstr::BString;
2122 use gix::{attrs::Assignment, odb::FindExt, Progress};
2223
2324 use crate::{
@@ -27,7 +28,7 @@ pub(crate) mod function { · 3 unmodified lines
2728
2829 pub fn validate_baseline(
2930 repo: gix::Repository,
30− pathspecs: Option<impl Iterator<Item = gix::path::Spec> + Send + 'static>,
31+ paths: Option<impl Iterator<Item = BString> + Send + 'static>,
3132 mut progress: impl Progress + 'static,
3233 mut out: impl io::Write,
3334 mut err: impl io::Write,
@@ -51,7 +52,7 @@ pub(crate) mod function { · 17 unmodified lines
5152 ignore = false;
5253 }
5354 let mut num_entries = None;
54− let pathspecs = pathspecs.map_or_else(
55+ let paths = paths.map_or_else(
5556 {
5657 let repo = repo.clone();
5758 let num_entries = &mut num_entries;
@@ -59,18 +60,16 @@ pub(crate) mod function { · 1 unmodified line
5960 let index = repo.index_or_load_from_head()?.into_owned();
6061 let (entries, path_backing) = index.into_parts().0.into_entries();
6162 *num_entries = Some(entries.len());
62− let iter = Box::new(entries.into_iter().map(move |e| {
63− gix::path::Spec::from_bytes(e.path_in(&path_backing)).expect("each entry path is a valid spec")
64− }));
65− Ok(iter as Box<dyn Iterator<Item = gix::path::Spec> + Send + 'static>)
63+ let iter = Box::new(entries.into_iter().map(move |e| e.path_in(&path_backing).to_owned()));
64+ Ok(iter as Box<dyn Iterator<Item = BString> + Send + 'static>)
6665 }
6766 },
68− |i| anyhow::Result::Ok(Box::new(i)),
67+ |paths| anyhow::Result::Ok(Box::new(paths)),
6968 )?;
7069
7170 let (tx_base, rx_base) = std::sync::mpsc::channel::<(String, Baseline)>();
7271 let feed_attrs = {
73− let (tx, rx) = std::sync::mpsc::sync_channel::<gix::path::Spec>(1);
72+ let (tx, rx) = std::sync::mpsc::sync_channel::<BString>(100);
7473 std::thread::spawn({
7574 let path = repo.path().to_owned();
7675 let tx_base = tx_base.clone();
@@ -89,12 +88,10 @@ pub(crate) mod function { · 12 unmodified lines
8988 move || -> anyhow::Result<()> {
9089 progress.init(num_entries, gix::progress::count("paths"));
9190 let start = std::time::Instant::now();
92− for spec in rx {
91+ for path in rx {
9392 progress.inc();
94− for path in spec.items() {
95− stdin.write_all(path.as_ref())?;
96− stdin.write_all(b"\n")?;
97− }
93+ stdin.write_all(&path)?;
94+ stdin.write_all(b"\n")?;
9895 }
9996 progress.show_throughput(start);
10097 Ok(())
@@ -123,7 +120,7 @@ pub(crate) mod function { · 22 unmodified lines
123120 })
124121 .transpose()?;
125122 let feed_excludes = ignore.then(|| {
126− let (tx, rx) = std::sync::mpsc::sync_channel::<gix::path::Spec>(1);
123+ let (tx, rx) = std::sync::mpsc::sync_channel::<BString>(100);
127124 std::thread::spawn({
128125 let path = work_dir.expect("present if we are here");
129126 let tx_base = tx_base.clone();
@@ -142,12 +139,10 @@ pub(crate) mod function { · 12 unmodified lines
142139 move || -> anyhow::Result<()> {
143140 progress.init(num_entries, gix::progress::count("paths"));
144141 let start = std::time::Instant::now();
145− for spec in rx {
142+ for path in rx {
146143 progress.inc();
147− for path in spec.items() {
148− stdin.write_all(path.as_ref())?;
149− stdin.write_all(b"\n")?;
150− }
144+ stdin.write_all(path.as_ref())?;
145+ stdin.write_all(b"\n")?;
151146 }
152147 progress.show_throughput(start);
153148 Ok(())
@@ -175,12 +170,12 @@ pub(crate) mod function { · 21 unmodified lines
175170 drop(tx_base);
176171
177172 std::thread::spawn(move || {
178− for spec in pathspecs {
179− if feed_attrs.send(spec.clone()).is_err() {
173+ for path in paths {
174+ if feed_attrs.send(path.clone()).is_err() {
180175 break;
181176 }
182177 if let Some(ch) = feed_excludes.as_ref() {
183− if ch.send(spec).is_err() {
178+ if ch.send(path).is_err() {
184179 break;
185180 }
186181 }
gitoxide-core/src/repository/exclude.rs+24−22
@@ -20,7 +20,7 @@ pub mod query { · 19 unmodified lines
2020
2121 pub fn query(
2222 repo: gix::Repository,
23− pathspecs: impl Iterator<Item = gix::path::Spec>,
23+ pathspecs: impl Iterator<Item = gix::pathspec::Pattern>,
2424 mut out: impl io::Write,
2525 mut err: impl io::Write,
2626 query::Options {
@@ -41,28 +41,30 @@ pub fn query( · 14 unmodified lines
4141 Default::default(),
4242 )?;
4343
44− let prefix = repo.prefix().expect("worktree - we have an index by now")?;
44+ // TODO(pathspec): actually use the search to find items. This looks like `gix` capabilities to put it all together.
45+ let search = gix::pathspec::Search::from_specs(
46+ pathspecs,
47+ repo.prefix()?.as_deref(),
48+ repo.work_dir().unwrap_or_else(|| repo.git_dir()),
49+ )?;
4550
46− for mut spec in pathspecs {
47− for path in spec.apply_prefix(&prefix).items() {
48− // TODO: what about paths that end in /? Pathspec might handle it, it's definitely something git considers
49− // even if the directory doesn't exist. Seems to work as long as these are kept in the spec.
50− let is_dir = gix::path::from_bstr(path).metadata().ok().map(|m| m.is_dir());
51− let entry = cache.at_entry(path, is_dir, |oid, buf| repo.objects.find_blob(oid, buf))?;
52− let match_ = entry
53− .matching_exclude_pattern()
54− .and_then(|m| (show_ignore_patterns || !m.pattern.is_negative()).then_some(m));
55− match match_ {
56− Some(m) => writeln!(
57− out,
58− "{}:{}:{}\t{}",
59− m.source.map(std::path::Path::to_string_lossy).unwrap_or_default(),
60− m.sequence_number,
61− m.pattern,
62− path
63− )?,
64− None => writeln!(out, "::\t{path}")?,
65− }
51+ for spec in search.into_patterns() {
52+ let path = spec.path();
53+ let is_dir = gix::path::from_bstr(path).metadata().ok().map(|m| m.is_dir());
54+ let entry = cache.at_entry(path, is_dir, |oid, buf| repo.objects.find_blob(oid, buf))?;
55+ let match_ = entry
56+ .matching_exclude_pattern()
57+ .and_then(|m| (show_ignore_patterns || !m.pattern.is_negative()).then_some(m));
58+ match match_ {
59+ Some(m) => writeln!(
60+ out,
61+ "{}:{}:{}\t{}",
62+ m.source.map(std::path::Path::to_string_lossy).unwrap_or_default(),
63+ m.sequence_number,
64+ m.pattern,
65+ path
66+ )?,
67+ None => writeln!(out, "::\t{path}")?,
6668 }
6769 }
6870
gitoxide-core/src/repository/index/entries.rs+148−57
@@ -4,9 +4,10 @@ pub struct Options { · 3 unmodified lines
44 /// If true, also show attributes
55 pub attributes: Option<Attributes>,
66 pub statistics: bool,
7+ pub simple: bool,
78 }
89
9−#[derive(Debug)]
10+#[derive(Debug, Copy, Clone)]
1011 pub enum Attributes {
1112 /// Look at worktree attributes and index as fallback.
1213 WorktreeAndIndex,
@@ -15,6 +16,7 @@ pub enum Attributes { · 2 unmodified lines
1516 }
1617
1718 pub(crate) mod function {
19+ use std::collections::BTreeSet;
1820 use std::{
1921 borrow::Cow,
2022 io::{BufWriter, Write},
@@ -26,9 +28,11 @@ pub(crate) mod function { · 5 unmodified lines
2628
2729 pub fn entries(
2830 repo: gix::Repository,
31+ pathspecs: Vec<gix::pathspec::Pattern>,
2932 out: impl std::io::Write,
3033 mut err: impl std::io::Write,
3134 Options {
35+ simple,
3236 format,
3337 attributes,
3438 statistics,
@@ -37,6 +41,12 @@ pub(crate) mod function { · 2 unmodified lines
3741 use crate::OutputFormat::*;
3842 let index = repo.index_or_load_from_head()?;
3943 let mut cache = attributes
44+ .or_else(|| {
45+ pathspecs
46+ .iter()
47+ .any(|spec| !spec.attributes.is_empty())
48+ .then_some(Attributes::Index)
49+ })
4050 .map(|attrs| {
4151 repo.attributes(
4252 &index,
@@ -70,52 +80,111 @@ pub(crate) mod function { · 27 unmodified lines
7080 ..Default::default()
7181 };
7282
73− let mut out = BufWriter::new(out);
83+ let mut out = BufWriter::with_capacity(64 * 1024, out);
7484 #[cfg(feature = "serde")]
7585 if let Json = format {
7686 out.write_all(b"[\n")?;
7787 }
78− let mut entries = index.entries().iter().peekable();
79− while let Some(entry) = entries.next() {
80− let attrs = cache
81− .as_mut()
82− .map(|(attrs, cache)| {
83− cache
84− .at_entry(entry.path(&index), None, |id, buf| repo.objects.find_blob(id, buf))
85− .map(|entry| {
86− let is_excluded = entry.is_excluded();
87− stats.excluded += usize::from(is_excluded);
88− let attributes: Vec<_> = {
89− entry.matching_attributes(attrs);
90− attrs.iter().map(|m| m.assignment.to_owned()).collect()
91− };
92− stats.with_attributes += usize::from(!attributes.is_empty());
93− Attrs {
94− is_excluded,
95− attributes,
96− }
88+ let mut search = gix::pathspec::Search::from_specs(
89+ pathspecs,
90+ repo.prefix()?.as_deref(),
91+ gix::path::realpath(repo.work_dir().unwrap_or_else(|| repo.git_dir()))?.as_ref(), // TODO(pathspec): this setup needs `gix`.
92+ )?;
93+ let mut all_attrs = statistics.then(BTreeSet::new);
94+ if let Some(entries) = index.prefixed_entries(search.common_prefix()) {
95+ stats.entries_after_prune = entries.len();
96+ let mut entries = entries.iter().peekable();
97+ while let Some(entry) = entries.next() {
98+ let mut last_match = None;
99+ let attrs = cache
100+ .as_mut()
101+ .and_then(|(attrs, cache)| {
102+ // If the user wants to see assigned attributes, we always have to match.
103+ attributes.is_some().then(|| {
104+ cache
105+ .at_entry(entry.path(&index), None, |id, buf| repo.objects.find_blob(id, buf))
106+ .map(|entry| {
107+ let is_excluded = entry.is_excluded();
108+ stats.excluded += usize::from(is_excluded);
109+ let attributes: Vec<_> = {
110+ last_match = Some(entry.matching_attributes(attrs));
111+ attrs.iter().map(|m| m.assignment.to_owned()).collect()
112+ };
113+ stats.with_attributes += usize::from(!attributes.is_empty());
114+ stats.max_attributes_per_path = stats.max_attributes_per_path.max(attributes.len());
115+ if let Some(attrs) = all_attrs.as_mut() {
116+ attributes.iter().for_each(|attr| {
117+ attrs.insert(attr.clone());
118+ });
119+ }
120+ Attrs {
121+ is_excluded,
122+ attributes,
123+ }
124+ })
97125 })
98− })
99− .transpose()?;
100− match format {
101− Human => to_human(&mut out, &index, entry, attrs)?,
102− #[cfg(feature = "serde")]
103− Json => to_json(&mut out, &index, entry, attrs, entries.peek().is_none())?,
126+ })
127+ .transpose()?;
128+
129+ // Note that we intentionally ignore `_case` so that we act like git does, attribute matching case is determined
130+ // by the repository, not the pathspec.
131+ if search
132+ .pattern_matching_relative_path(entry.path(&index), Some(false), |rela_path, _case, is_dir, out| {
133+ cache
134+ .as_mut()
135+ .map(|(attrs, cache)| {
136+ match last_match {
137+ // The user wants the attributes for display, so the match happened already.
138+ Some(matched) => {
139+ attrs.copy_into(cache.attributes_collection(), out);
140+ matched
141+ }
142+ // The user doesn't want attributes, so we set the cache position on demand only
143+ None => cache
144+ .at_entry(rela_path, Some(is_dir), |id, buf| repo.objects.find_blob(id, buf))
145+ .ok()
146+ .map(|platform| platform.matching_attributes(out))
147+ .unwrap_or_default(),
148+ }
149+ })
150+ .unwrap_or_default()
151+ })
152+ .map_or(true, |m| m.is_excluded())
153+ {
154+ continue;
155+ }
156+ match format {
157+ Human => {
158+ if simple {
159+ to_human_simple(&mut out, &index, entry, attrs)
160+ } else {
161+ to_human(&mut out, &index, entry, attrs)
162+ }?
163+ }
164+ #[cfg(feature = "serde")]
165+ Json => to_json(&mut out, &index, entry, attrs, entries.peek().is_none())?,
166+ }
104167 }
105− }
106168
107− #[cfg(feature = "serde")]
108− if format == Json {
109− out.write_all(b"]\n")?;
110− out.flush()?;
111− if statistics {
112− serde_json::to_writer_pretty(&mut err, &stats)?;
169+ #[cfg(feature = "serde")]
170+ if format == Json {
171+ out.write_all(b"]\n")?;
172+ out.flush()?;
173+ if statistics {
174+ serde_json::to_writer_pretty(&mut err, &stats)?;
175+ }
176+ }
177+ if format == Human && statistics {
178+ out.flush()?;
179+ stats.cache = cache.map(|c| *c.1.statistics());
180+ writeln!(err, "{stats:#?}")?;
181+ if let Some(attrs) = all_attrs.filter(|a| !a.is_empty()) {
182+ writeln!(err, "All encountered attributes:")?;
183+ for attr in attrs {
184+ writeln!(err, "\t{attr}", attr = attr.as_ref())?;
185+ }
186+ }
113187 }
114− }
115− if format == Human && statistics {
116− out.flush()?;
117− stats.cache = cache.map(|c| *c.1.statistics());
118− writeln!(err, "{stats:#?}")?;
119188 }
120189 Ok(())
121190 }
@@ -131,8 +200,10 @@ pub(crate) mod function { · 9 unmodified lines
131200 struct Statistics {
132201 #[allow(dead_code)] // Not really dead, but Debug doesn't count for it even though it's crucial.
133202 pub entries: usize,
203+ pub entries_after_prune: usize,
134204 pub excluded: usize,
135205 pub with_attributes: usize,
206+ pub max_attributes_per_path: usize,
136207 pub cache: Option<gix::worktree::cache::Statistics>,
137208 }
138209
@@ -175,6 +246,22 @@ pub(crate) mod function { · 36 unmodified lines
175246 Ok(())
176247 }
177248
249+ fn to_human_simple(
250+ out: &mut impl std::io::Write,
251+ file: &gix::index::File,
252+ entry: &gix::index::Entry,
253+ attrs: Option<Attrs>,
254+ ) -> std::io::Result<()> {
255+ match attrs {
256+ Some(attrs) => {
257+ out.write_all(entry.path(file))?;
258+ out.write_all(print_attrs(Some(attrs)).as_bytes())
259+ }
260+ None => out.write_all(entry.path(file)),
261+ }?;
262+ out.write_all(b"\n")
263+ }
264+
178265 fn to_human(
179266 out: &mut impl std::io::Write,
180267 file: &gix::index::File,
@@ -198,24 +285,28 @@ pub(crate) mod function { · 17 unmodified lines
198285 entry.mode,
199286 entry.id,
200287 entry.path(file),
201− attrs.map_or(Cow::Borrowed(""), |a| {
202− let mut buf = String::new();
203− if a.is_excluded {
204− buf.push_str(" ❌");
205− }
206− if !a.attributes.is_empty() {
207− buf.push_str(" (");
208− for assignment in a.attributes {
209− use std::fmt::Write;
210− write!(&mut buf, "{}", assignment.as_ref()).ok();
211− buf.push_str(", ");
212− }
213− buf.pop();
214− buf.pop();
215− buf.push(')');
216− }
217− buf.into()
218− })
288+ print_attrs(attrs)
219289 )
220290 }
291+
292+ fn print_attrs(attrs: Option<Attrs>) -> Cow<'static, str> {
293+ attrs.map_or(Cow::Borrowed(""), |a| {
294+ let mut buf = String::new();
295+ if a.is_excluded {
296+ buf.push_str(" ❌");
297+ }
298+ if !a.attributes.is_empty() {
299+ buf.push_str(" (");
300+ for assignment in a.attributes {
301+ use std::fmt::Write;
302+ write!(&mut buf, "{}", assignment.as_ref()).ok();
303+ buf.push_str(", ");
304+ }
305+ buf.pop();
306+ buf.pop();
307+ buf.push(')');
308+ }
309+ buf.into()
310+ })
311+ }
221312 }
gix-attributes/src/search/attributes.rs+9−1
@@ -181,7 +181,9 @@ impl Pattern for Attributes { · 180 unmodified lines
181181 })
182182 .collect()
183183 }
184+}
184185
186+impl Attributes {
185187 fn may_use_glob_pattern(pattern: &gix_glob::Pattern) -> bool {
186188 pattern.mode != macro_mode()
187189 }
@@ -229,7 +231,13 @@ fn pattern_matching_relative_path( · 41 unmodified lines
229231 Value::Assignments(attrs) => attrs,
230232 };
231233 if out.has_unspecified_attributes(attrs.iter().map(|attr| attr.id))
232− && pattern.matches_repo_relative_path(relative_path, basename_start_pos, is_dir, case)
234+ && pattern.matches_repo_relative_path(
235+ relative_path,
236+ basename_start_pos,
237+ is_dir,
238+ case,
239+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
240+ )
233241 {
234242 let all_filled = out.fill_attributes(attrs.iter(), pattern, list.source.as_ref(), *sequence_number);
235243 if all_filled {
gix-attributes/src/search/outcome.rs+43−0
@@ -73,6 +73,26 @@ impl Outcome { · 72 unmodified lines
7373 self.selected.iter().filter(|(_name, id)| id.is_some()).count()
7474 });
7575 }
76+
77+ /// A performance optimization which allows results from this instance to be efficiently copied over to `dest`.
78+ /// For this to work, `collection` must be the one used to initialize our state, and `dest` should not have been initialized
79+ /// with any meaningful collection initially, i.e. be empty the first time this method is called.
80+ ///
81+ /// Note that it's safe to call it multiple times, so that it can be called after this instance was used to store a search result.
82+ pub fn copy_into(&self, collection: &MetadataCollection, dest: &mut Self) {
83+ dest.initialize(collection);
84+ dest.matches_by_id = self.matches_by_id.clone();
85+ if dest.patterns.len() != self.patterns.len() {
86+ dest.patterns = self.patterns.clone();
87+ }
88+ if dest.assignments.len() != self.assignments.len() {
89+ dest.assignments = self.assignments.clone();
90+ }
91+ if dest.source_paths.len() != self.source_paths.len() {
92+ dest.source_paths = self.source_paths.clone();
93+ }
94+ dest.remaining = self.remaining;
95+ }
7696 }
7797
7898 /// Access
@@ -223,6 +243,29 @@ impl Outcome { · 144 unmodified lines
223243 }
224244 }
225245
246+impl std::fmt::Debug for Outcome {
247+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248+ struct AsDisplay<'a>(&'a dyn std::fmt::Display);
249+ impl std::fmt::Debug for AsDisplay<'_> {
250+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251+ self.0.fmt(f)
252+ }
253+ }
254+
255+ let mut dbg = f.debug_tuple("Outcome");
256+ if self.selected.is_empty() {
257+ for match_ in self.iter() {
258+ dbg.field(&AsDisplay(&match_.assignment));
259+ }
260+ } else {
261+ for match_ in self.iter_selected() {
262+ dbg.field(&AsDisplay(&match_.assignment));
263+ }
264+ }
265+ dbg.finish()
266+ }
267+}
268+
226269 /// Mutation
227270 impl MetadataCollection {
228271 /// Assign order ids to each attribute either in macros (along with macros themselves) or attributes of patterns, and store
gix-attributes/src/search/refmap.rs+3−0
@@ -21,6 +21,9 @@ impl<T> RefMap<T> · 20 unmodified lines
2121 where
2222 T: Hash + Clone,
2323 {
24+ pub(crate) fn len(&self) -> usize {
25+ self.0.len()
26+ }
2427 pub(crate) fn insert(&mut self, value: &T) -> RefMapKey {
2528 let mut s = DefaultHasher::new();
2629 value.hash(&mut s);
gix-attributes/tests/search/mod.rs+23−3
@@ -141,8 +141,10 @@ fn all_attributes_are_listed_in_declaration_order() -> crate::Result { · 140 unmodified lines
141141 true, /* use macros */
142142 )?;
143143
144− let mut out = gix_attributes::search::Outcome::default();
144+ let mut out = Outcome::default();
145145 out.initialize(&collection);
146+ let mut alt = Outcome::default();
147+ alt.initialize(&collection);
146148
147149 let mut orders = collection
148150 .iter()
@@ -206,10 +208,13 @@ fn all_attributes_are_listed_in_declaration_order() -> crate::Result { · 57 unmodified lines
206208 assert_references(&out);
207209 let actual: Vec<_> = out.iter().map(|m| m.assignment).collect();
208210 assert_eq!(
209− by_name(actual),
211+ by_name(actual.clone()),
210212 by_name(expected),
211213 "{rela_path}: the order of everything matches perfectly"
212214 );
215+ out.copy_into(&collection, &mut alt);
216+ let alt_actual: Vec<_> = alt.iter().map(|m| m.assignment).collect();
217+ assert_eq!(alt_actual, actual);
213218 }
214219 assert_eq!(
215220 out.iter().count(),
@@ -234,8 +239,10 @@ fn given_attributes_are_made_available_in_given_order() -> crate::Result { · 18 unmodified lines
234239 true, /* use macros */
235240 )?;
236241
237− let mut out = gix_attributes::search::Outcome::default();
242+ let mut out = Outcome::default();
238243 out.initialize_with_selection(&collection, ["my-binary", "recursive", "unspecified"]);
244+ let mut alt = Outcome::default();
245+ alt.initialize_with_selection(&collection, ["my-binary", "unspecified"]);
239246
240247 for (rela_path, expected) in (baseline::Expectations { lines: input.lines() }) {
241248 out.reset();
@@ -246,6 +253,10 @@ fn given_attributes_are_made_available_in_given_order() -> crate::Result { · 4 unmodified lines
246253 actual, expected,
247254 "{rela_path}: the order of everything matches perfectly"
248255 );
256+ out.copy_into(&collection, &mut alt);
257+ let alt_actual: Vec<_> = alt.iter_selected().map(|m| m.assignment).collect();
258+ assert_eq!(alt_actual[0], actual[0]);
259+ assert_eq!(alt_actual[1], actual[2]);
249260 }
250261 assert_eq!(
251262 out.iter().count(),
@@ -255,6 +266,15 @@ fn given_attributes_are_made_available_in_given_order() -> crate::Result { · 3 unmodified lines
255266 Ok(())
256267 }
257268
269+#[test]
270+fn size_of_outcome() {
271+ assert_eq!(
272+ std::mem::size_of::<Outcome>(),
273+ 904,
274+ "it's quite big, shouldn't change without us noticing"
275+ )
276+}
277+
258278 fn by_name(assignments: Vec<AssignmentRef>) -> BTreeMap<NameRef, StateRef> {
259279 assignments.into_iter().map(|a| (a.name, a.state)).collect()
260280 }
gix-glob/src/parse.rs+11−7
@@ -6,19 +6,23 @@ use crate::{pattern, pattern::Mode}; · 5 unmodified lines
66 /// A sloppy parser that performs only the most basic checks, providing additional information
77 /// using `pattern::Mode` flags.
88 ///
9+/// If `may_alter` is `false`, we won't parse leading `!` or its escaped form.
10+///
911 /// Returns `(pattern, mode, no_wildcard_len)`
10−pub fn pattern(mut pat: &[u8]) -> Option<(&[u8], pattern::Mode, Option<usize>)> {
12+pub fn pattern(mut pat: &[u8], may_alter: bool) -> Option<(&[u8], pattern::Mode, Option<usize>)> {
1113 let mut mode = Mode::empty();
1214 if pat.is_empty() {
1315 return None;
1416 };
15− if pat.first() == Some(&b'!') {
16− mode |= Mode::NEGATIVE;
17− pat = &pat[1..];
18− } else if pat.first() == Some(&b'\\') {
19− let second = pat.get(1);
20− if second == Some(&b'!') || second == Some(&b'#') {
17+ if may_alter {
18+ if pat.first() == Some(&b'!') {
19+ mode |= Mode::NEGATIVE;
2120 pat = &pat[1..];
21+ } else if pat.first() == Some(&b'\\') {
22+ let second = pat.get(1);
23+ if second == Some(&b'!') || second == Some(&b'#') {
24+ pat = &pat[1..];
25+ }
2226 }
2327 }
2428 if pat.iter().all(u8::is_ascii_whitespace) {
gix-glob/src/pattern.rs+26−8
@@ -44,7 +44,18 @@ pub enum Case { · 43 unmodified lines
4444 impl Pattern {
4545 /// Parse the given `text` as pattern, or return `None` if `text` was empty.
4646 pub fn from_bytes(text: &[u8]) -> Option<Self> {
47− crate::parse::pattern(text).map(|(text, mode, first_wildcard_pos)| Pattern {
47+ crate::parse::pattern(text, true).map(|(text, mode, first_wildcard_pos)| Pattern {
48+ text: text.into(),
49+ mode,
50+ first_wildcard_pos,
51+ })
52+ }
53+
54+ /// Parse the given `text` as pattern without supporting leading `!` or `\\!` , or return `None` if `text` was empty.
55+ ///
56+ /// This assures that `text` remains entirely unaltered, but removes built-in support for negation as well.
57+ pub fn from_bytes_without_negation(text: &[u8]) -> Option<Self> {
58+ crate::parse::pattern(text, false).map(|(text, mode, first_wildcard_pos)| Pattern {
4859 text: text.into(),
4960 mode,
5061 first_wildcard_pos,
@@ -65,30 +76,37 @@ impl Pattern { · 14 unmodified lines
6576 /// We may take various shortcuts which is when `basename_start_pos` and `is_dir` come into play.
6677 /// `basename_start_pos` is the index at which the `path`'s basename starts.
6778 ///
68− /// Lastly, `case` folding can be configured as well.
79+ /// `case` folding can be configured as well.
80+ /// `mode` is used to control how [`crate::wildmatch()`] should operate.
6981 pub fn matches_repo_relative_path<'a>(
7082 &self,
7183 path: impl Into<&'a BStr>,
7284 basename_start_pos: Option<usize>,
7385 is_dir: Option<bool>,
7486 case: Case,
87+ mode: wildmatch::Mode,
7588 ) -> bool {
7689 let is_dir = is_dir.unwrap_or(false);
7790 if !is_dir && self.mode.contains(pattern::Mode::MUST_BE_DIR) {
7891 return false;
7992 }
8093
81− let flags = wildmatch::Mode::NO_MATCH_SLASH_LITERAL
94+ let flags = mode
8295 | match case {
8396 Case::Fold => wildmatch::Mode::IGNORE_CASE,
8497 Case::Sensitive => wildmatch::Mode::empty(),
8598 };
8699 let path = path.into();
87− debug_assert_eq!(
88− basename_start_pos,
89− path.rfind_byte(b'/').map(|p| p + 1),
90− "BUG: invalid cached basename_start_pos provided"
91− );
100+ #[cfg(debug_assertions)]
101+ {
102+ if basename_start_pos.is_some() {
103+ debug_assert_eq!(
104+ basename_start_pos,
105+ path.rfind_byte(b'/').map(|p| p + 1),
106+ "BUG: invalid cached basename_start_pos provided"
107+ );
108+ }
109+ }
92110 debug_assert!(!path.starts_with(b"/"), "input path must be relative");
93111
94112 if self.mode.contains(pattern::Mode::NO_SUB_DIR) && !self.mode.contains(pattern::Mode::ABSOLUTE) {
gix-glob/src/search/mod.rs+0−3
@@ -16,9 +16,6 @@ pub trait Pattern: Clone + PartialEq + Eq + std::fmt::Debug + std::hash::Hash + · 15 unmodified lines
1616
1717 /// Parse all patterns in `bytes` line by line, ignoring lines with errors, and collect them.
1818 fn bytes_to_patterns(bytes: &[u8], source: &Path) -> Vec<pattern::Mapping<Self::Value>>;
19−
20− /// Returns true if the given pattern may be used for matching.
21− fn may_use_glob_pattern(pattern: &crate::Pattern) -> bool;
2219 }
2320
2421 /// Add the given file at `source` if it exists, otherwise do nothing.
gix-glob/tests/parse/mod.rs+10−0
@@ -66,11 +66,21 @@ fn leading_exclamation_mark_negates_pattern() { · 65 unmodified lines
6666 gix_glob::parse(b"!hello"),
6767 pat("hello", Mode::NEGATIVE | Mode::NO_SUB_DIR, None)
6868 );
69+ assert_eq!(
70+ gix_glob::Pattern::from_bytes_without_negation(b"!hello"),
71+ pat("!hello", Mode::NO_SUB_DIR, None),
72+ "negation can be disabled entirely"
73+ );
6974 }
7075
7176 #[test]
7277 fn leading_exclamation_marks_can_be_escaped_with_backslash() {
7378 assert_eq!(gix_glob::parse(br"\!hello"), pat("!hello", Mode::NO_SUB_DIR, None));
79+ assert_eq!(
80+ gix_glob::Pattern::from_bytes_without_negation(br"\!hello"),
81+ pat("\\!hello", Mode::NO_SUB_DIR, Some(0)),
82+ "negation can be disabled entirely, leaving escapes in place"
83+ );
7484 }
7585
7686 #[test]
gix-glob/tests/pattern/matching.rs+28−4
@@ -68,7 +68,13 @@ fn compare_baseline_with_ours() { · 67 unmodified lines
6868 );
6969 match std::panic::catch_unwind(|| {
7070 let pattern = pat(pattern);
71− pattern.matches_repo_relative_path(value, basename_start_pos(value), None, *case)
71+ pattern.matches_repo_relative_path(
72+ value,
73+ basename_start_pos(value),
74+ None,
75+ *case,
76+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
77+ )
7278 }) {
7379 Ok(actual_match) => {
7480 if actual_match == is_match {
@@ -105,11 +111,23 @@ fn non_dirs_for_must_be_dir_patterns_are_ignored() { · 30 unmodified lines
105111 );
106112 let path = "hello";
107113 assert!(
108− !pattern.matches_repo_relative_path(path, None, false.into() /* is-dir */, Case::Sensitive),
114+ !pattern.matches_repo_relative_path(
115+ path,
116+ None,
117+ false.into(), /* is-dir */
118+ Case::Sensitive,
119+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL
120+ ),
109121 "non-dirs never match a dir pattern"
110122 );
111123 assert!(
112− pattern.matches_repo_relative_path(path, None, true.into() /* is-dir */, Case::Sensitive),
124+ pattern.matches_repo_relative_path(
125+ path,
126+ None,
127+ true.into(), /* is-dir */
128+ Case::Sensitive,
129+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL
130+ ),
113131 "dirs can match a dir pattern with the normal rules"
114132 );
115133 }
@@ -317,7 +335,13 @@ fn match_file<'a>(pattern: &gix_glob::Pattern, path: impl Into<&'a BStr>, case: · 201 unmodified lines
317335
318336 fn match_path<'a>(pattern: &gix_glob::Pattern, path: impl Into<&'a BStr>, is_dir: Option<bool>, case: Case) -> bool {
319337 let path = path.into();
320− pattern.matches_repo_relative_path(path, basename_start_pos(path), is_dir, case)
338+ pattern.matches_repo_relative_path(
339+ path,
340+ basename_start_pos(path),
341+ is_dir,
342+ case,
343+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
344+ )
321345 }
322346
323347 fn basename_start_pos(value: &BStr) -> Option<usize> {
gix-glob/tests/search/pattern.rs+0−4
@@ -18,10 +18,6 @@ mod list { · 17 unmodified lines
1818 fn bytes_to_patterns(_bytes: &[u8], _source: &Path) -> Vec<Mapping<Self::Value>> {
1919 vec![]
2020 }
21−
22− fn may_use_glob_pattern(_pattern: &gix_glob::Pattern) -> bool {
23− unreachable!("won't be called")
24− }
2521 }
2622
2723 #[test]
gix-glob/tests/wildmatch/mod.rs+7−1
@@ -367,7 +367,13 @@ impl Display for MatchResult { · 366 unmodified lines
367367 }
368368
369369 fn match_file_path(pattern: &gix_glob::Pattern, path: &str, case: Case) -> bool {
370− pattern.matches_repo_relative_path(path, basename_of(path), false.into() /* is_dir */, case)
370+ pattern.matches_repo_relative_path(
371+ path,
372+ basename_of(path),
373+ false.into(), /* is_dir */
374+ case,
375+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
376+ )
371377 }
372378 fn basename_of(path: &str) -> Option<usize> {
373379 path.rfind('/').map(|pos| pos + 1)
gix-ignore/src/search.rs+32−33
@@ -35,10 +35,6 @@ impl Pattern for Ignore { · 34 unmodified lines
3535 })
3636 .collect()
3737 }
38−
39− fn may_use_glob_pattern(_pattern: &gix_glob::Pattern) -> bool {
40− true
41− }
4238 }
4339
4440 /// Instantiation of a search for ignore patterns.
@@ -115,25 +111,27 @@ pub fn pattern_matching_relative_path<'a>( · 70 unmodified lines
115111 ) -> Option<Match<'a>> {
116112 let (relative_path, basename_start_pos) =
117113 list.strip_base_handle_recompute_basename_pos(relative_path, basename_pos, case)?;
118− list.patterns
119− .iter()
120− .rev()
121− .filter(|pm| Ignore::may_use_glob_pattern(&pm.pattern))
122− .find_map(
123− |pattern::Mapping {
124− pattern,
125− value: (),
126− sequence_number,
127− }| {
128− pattern
129− .matches_repo_relative_path(relative_path, basename_start_pos, is_dir, case)
130− .then_some(Match {
131− pattern,
132− source: list.source.as_deref(),
133− sequence_number: *sequence_number,
134− })
135− },
136− )
114+ list.patterns.iter().rev().find_map(
115+ |pattern::Mapping {
116+ pattern,
117+ value: (),
118+ sequence_number,
119+ }| {
120+ pattern
121+ .matches_repo_relative_path(
122+ relative_path,
123+ basename_start_pos,
124+ is_dir,
125+ case,
126+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
127+ )
128+ .then_some(Match {
129+ pattern,
130+ source: list.source.as_deref(),
131+ sequence_number: *sequence_number,
132+ })
133+ },
134+ )
137135 }
138136
139137 /// Like [`pattern_matching_relative_path()`], but returns an index to the pattern
@@ -147,16 +145,17 @@ pub fn pattern_idx_matching_relative_path( · 7 unmodified lines
147145 ) -> Option<usize> {
148146 let (relative_path, basename_start_pos) =
149147 list.strip_base_handle_recompute_basename_pos(relative_path, basename_pos, case)?;
150− list.patterns
151− .iter()
152− .enumerate()
153− .rev()
154− .filter(|(_, pm)| Ignore::may_use_glob_pattern(&pm.pattern))
155− .find_map(|(idx, pm)| {
156− pm.pattern
157− .matches_repo_relative_path(relative_path, basename_start_pos, is_dir, case)
158− .then_some(idx)
159− })
148+ list.patterns.iter().enumerate().rev().find_map(|(idx, pm)| {
149+ pm.pattern
150+ .matches_repo_relative_path(
151+ relative_path,
152+ basename_start_pos,
153+ is_dir,
154+ case,
155+ gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
156+ )
157+ .then_some(idx)
158+ })
160159 }
161160
162161 /// Matching of ignore patterns.
gix-index/src/access/mod.rs+28−0
@@ -158,6 +158,34 @@ impl State { · 157 unmodified lines
158158 Some(&self.entries[idx])
159159 }
160160
161+ /// Return the slice of entries which all share the same `prefix`, or `None` if there isn't a single such entry.
162+ pub fn prefixed_entries(&self, prefix: &BStr) -> Option<&[Entry]> {
163+ if prefix.is_empty() {
164+ return Some(self.entries());
165+ }
166+ let prefix_len = prefix.len();
167+ let mut low = self
168+ .entries
169+ .partition_point(|e| e.path(self).get(..prefix_len).map_or(true, |p| p < prefix));
170+ let mut high = low
171+ + self.entries[low..].partition_point(|e| e.path(self).get(..prefix_len).map_or(false, |p| p <= prefix));
172+
173+ let low_entry = &self.entries[low];
174+ if low_entry.stage() != 0 {
175+ low = self
176+ .entry_index_by_idx_and_stage(low_entry.path(self), low, 0, low_entry.stage().cmp(&0))
177+ .unwrap_or(low);
178+ }
179+ if let Some(high_entry) = self.entries.get(high) {
180+ if low_entry.stage() != 2 {
181+ high = self
182+ .entry_index_by_idx_and_stage(high_entry.path(self), high, 2, high_entry.stage().cmp(&2))
183+ .unwrap_or(high);
184+ }
185+ }
186+ (low != high).then_some(low..high).map(|range| &self.entries[range])
187+ }
188+
161189 /// Return the entry at `idx` or _panic_ if the index is out of bounds.
162190 ///
163191 /// The `idx` is typically returned by [`entry_by_path_and_stage()`][State::entry_by_path_and_stage()].
gix-index/tests/index/access.rs+38−1
@@ -28,7 +28,17 @@ fn entry_by_path_with_conflicting_file() { · 27 unmodified lines
2828 file.entry_by_path("file".into()).expect("found").stage(),
2929 2,
3030 "we always find our stage while in a merge"
31− )
31+ );
32+ assert_eq!(
33+ file.prefixed_entries("fil".into()).expect("present"),
34+ file.entries(),
35+ "it's possible to get the entire range"
36+ );
37+ assert_eq!(
38+ file.prefixed_entries("".into()).expect("present"),
39+ file.entries(),
40+ "empty prefix matches all"
41+ );
3242 }
3343
3444 #[test]
@@ -65,4 +75,31 @@ fn sort_entries() { · 30 unmodified lines
6575 new_entry_path,
6676 "we can find the correct entry now"
6777 );
78+
79+ check_prefix(&file, "a", &["a", "an initially incorrectly ordered entry"]);
80+ check_prefix(
81+ &file,
82+ "d",
83+ &["d/a", "d/b", "d/c", "d/last/123", "d/last/34", "d/last/6"],
84+ );
85+ check_prefix(
86+ &file,
87+ "d/",
88+ &["d/a", "d/b", "d/c", "d/last/123", "d/last/34", "d/last/6"],
89+ );
90+ check_prefix(&file, "d/last", &["d/last/123", "d/last/34", "d/last/6"]);
91+ check_prefix(&file, "d/las", &["d/last/123", "d/last/34", "d/last/6"]);
92+ check_prefix(&file, "x", &["x"]);
93+}
94+
95+fn check_prefix(index: &gix_index::State, prefix: &str, expected: &[&str]) {
96+ assert_eq!(
97+ index
98+ .prefixed_entries(prefix.into())
99+ .expect("present")
100+ .iter()
101+ .map(|e| e.path(index))
102+ .collect::<Vec<_>>(),
103+ expected
104+ );
68105 }
gix-path/src/convert.rs+1−0
@@ -239,6 +239,7 @@ pub fn to_windows_separators<'a>(path: impl Into<Cow<'a, BStr>>) -> Cow<'a, BStr · 238 unmodified lines
239239 /// Resolve relative components virtually without accessing the file system, e.g. turn `a/./b/c/.././..` into `a`,
240240 /// without keeping intermediate `..` and `/a/../b/..` becomes `/`.
241241 /// If the input path was relative and ends up being the `current_dir`, `.` is returned instead of the full path to `current_dir`.
242+/// Note that single `.` components as well as duplicate separators are left untouched.
242243 ///
243244 /// This is particularly useful when manipulating paths that are based on user input, and not resolving intermediate
244245 /// symlinks keeps the path similar to what the user provided. If that's not desirable, use `[realpath()][crate::realpath()`
gix-path/src/lib.rs+0−7
@@ -50,19 +50,12 @@ · 49 unmodified lines
5050 #![forbid(unsafe_code)]
5151
5252 /// A dummy type to represent path specs and help finding all spots that take path specs once it is implemented.
53−
54−/// A preliminary version of a path-spec based on glances of the code.
55−#[derive(Clone, Debug)]
56−pub struct Spec(bstr::BString);
57−
5853 mod convert;
5954 pub use convert::*;
6055
6156 mod util;
6257 pub use util::is_absolute;
6358
64−mod spec;
65−
6659 ///
6760 pub mod realpath;
6861 pub use realpath::function::{realpath, realpath_opts};
gix-path/src/spec.rs+0−53
@@ -1,53 +0,0 @@
1−use std::ffi::OsStr;
2−
3−use bstr::{BStr, ByteSlice, ByteVec};
4−
5−use crate::Spec;
6−
7−impl std::convert::TryFrom<&OsStr> for Spec {
8− type Error = crate::Utf8Error;
9−
10− fn try_from(value: &OsStr) -> Result<Self, Self::Error> {
11− crate::os_str_into_bstr(value).map(|value| {
12− assert_valid_hack(value);
13− Spec(value.into())
14− })
15− }
16−}
17−
18−fn assert_valid_hack(input: &BStr) {
19− assert!(!input.contains_str(b"/../"));
20− assert!(!input.contains_str(b"/./"));
21− assert!(!input.starts_with_str(b"../"));
22− assert!(!input.starts_with_str(b"./"));
23− assert!(!input.starts_with_str(b"/"));
24−}
25−
26−impl Spec {
27− /// Parse `input` into a `Spec` or `None` if it could not be parsed
28− // TODO: tests, actual implementation probably via `gix-pathspec` to make use of the crate after all.
29− pub fn from_bytes(input: &BStr) -> Option<Self> {
30− assert_valid_hack(input);
31− Spec(input.into()).into()
32− }
33− /// Return all paths described by this path spec, using slashes on all platforms.
34− pub fn items(&self) -> impl Iterator<Item = &BStr> {
35− std::iter::once(self.0.as_bstr())
36− }
37− /// Adjust this path specification according to the given `prefix`, which may be empty to indicate we are the at work-tree root.
38− // TODO: this is a hack, needs test and time to do according to spec. This is just a minimum version to have -something-.
39− pub fn apply_prefix(&mut self, prefix: &std::path::Path) -> &Self {
40− // many more things we can't handle. `Path` never ends with trailing path separator.
41− let prefix = crate::into_bstr(prefix);
42− if !prefix.is_empty() {
43− let mut prefix = crate::to_unix_separators_on_windows(prefix);
44− {
45− let path = prefix.to_mut();
46− path.push_byte(b'/');
47− path.extend_from_slice(&self.0);
48− }
49− self.0 = prefix.into_owned();
50− }
51− self
52− }
53−}
gix-path/tests/convert/normalize.rs+1−0
@@ -117,6 +117,7 @@ fn trailing_relative_components_are_resolved() { · 116 unmodified lines
117117 ("./a/./b/..", "./a/."),
118118 ("/a/./b/c/.././../.", "/a"),
119119 ("/a/./b", "/a/./b"),
120+ ("././/a/./b", "././/a/./b"),
120121 ("/a/././c/.././../.", "/"),
121122 ("/a/b/../c/../..", "/"),
122123 ("C:/hello/../a", "C:/a"),
gix-pathspec/Cargo.toml+1−0
@@ -13,6 +13,7 @@ doctest = false · 12 unmodified lines
1313
1414 [dependencies]
1515 gix-glob = { version = "^0.10.2", path = "../gix-glob" }
16+gix-path = { version = "^0.8.4", path = "../gix-path" }
1617 gix-attributes = { version = "^0.16.0", path = "../gix-attributes" }
1718
1819 bstr = { version = "1.3.0", default-features = false, features = ["std"]}
gix-pathspec/fuzz/fuzz_targets/parse.rs+1−1
@@ -2,5 +2,5 @@ · 1 unmodified line
22 use libfuzzer_sys::fuzz_target;
33
44 fuzz_target!(|data: &[u8]| {
5− let _a = gix_pathspec::parse(data);
5+ let _a = gix_pathspec::parse(data, Default::default());
66 });
gix-pathspec/src/lib.rs+66−9
@@ -5,17 +5,61 @@ · 4 unmodified lines
55
66 use bitflags::bitflags;
77 use bstr::BString;
8+use std::path::PathBuf;
9+
10+///
11+pub mod normalize {
12+ use std::path::PathBuf;
13+
14+ /// The error returned by [Pattern::normalize()](super::Pattern::normalize()).
15+ #[derive(Debug, thiserror::Error)]
16+ #[allow(missing_docs)]
17+ pub enum Error {
18+ #[error("The path '{}' is not inside of the worktree '{}'", path.display(), worktree_path.display())]
19+ AbsolutePathOutsideOfWorktree { path: PathBuf, worktree_path: PathBuf },
20+ #[error("The path '{}' leaves the repository", path.display())]
21+ OutsideOfWorktree { path: PathBuf },
22+ }
23+}
24+
25+mod pattern;
26+
27+///
28+pub mod search;
829
930 ///
1031 pub mod parse;
1132
33+/// A lists of pathspec patterns, possibly from a file.
34+///
35+/// Pathspecs are generally relative to the root of the repository.
36+#[derive(Debug, Clone)]
37+pub struct Search {
38+ /// Patterns and their associated data in the order they were loaded in or specified,
39+ /// the line number in its source file or its sequence number (_`(pattern, value, line_number)`_).
40+ ///
41+ /// During matching, this order is reversed.
42+ patterns: Vec<gix_glob::search::pattern::Mapping<search::Spec>>,
43+
44+ /// The path from which the patterns were read, or `None` if the patterns
45+ /// don't originate in a file on disk.
46+ pub source: Option<PathBuf>,
47+
48+ /// If `true`, this means all `patterns` are exclude patterns. This means that if there is no match
49+ /// (which would exclude an item), we would actually match it for lack of exclusion.
50+ all_patterns_are_excluded: bool,
51+ /// The amount of bytes that are in common among all `patterns` and that aren't matched case-insensitively
52+ common_prefix_len: usize,
53+}
54+
1255 /// The output of a pathspec [parsing][parse()] operation. It can be used to match against a one or more paths.
13−#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
56+#[derive(Default, PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
1457 pub struct Pattern {
15− /// The path part of a pathspec.
58+ /// The path part of a pathspec, which is typically a path possibly mixed with glob patterns.
59+ /// Note that it might be an empty string as well.
1660 ///
17− /// `:(top,literal,icase,attr,exclude)some/path` would yield `some/path`.
18− pub path: BString,
61+ /// For example, `:(top,literal,icase,attr,exclude)some/path` would yield `some/path`.
62+ path: BString,
1963 /// All magic signatures that were included in the pathspec.
2064 pub signature: MagicSignature,
2165 /// The search mode of the pathspec.
@@ -24,11 +68,18 @@ pub struct Pattern { · 2 unmodified lines
2468 ///
2569 /// `:(attr:a=one b=):path` would yield attribute `a` and `b`.
2670 pub attributes: Vec<gix_attributes::Assignment>,
71+ /// If `true`, we are a special Nil pattern and always match.
72+ nil: bool,
73+ /// The length of bytes in `path` that belong to the prefix, which will always be matched case-insensitively.
74+ /// That way, even though pathspecs are applied from the top, we can emulate having changed directory into
75+ /// a specific sub-directory in a case-sensitive file-system.
76+ /// Is set by [Pattern::normalize()].
77+ prefix_len: usize,
2778 }
2879
2980 bitflags! {
3081 /// Flags to represent 'magic signatures' which are parsed behind colons, like `:top:`.
31− #[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
82+ #[derive(Default, PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
3283 pub struct MagicSignature: u32 {
3384 /// Matches patterns from the root of the repository
3485 const TOP = 1 << 0;
@@ -36,12 +87,15 @@ bitflags! { · 1 unmodified line
3687 const ICASE = 1 << 1;
3788 /// Excludes the matching patterns from the previous results
3889 const EXCLUDE = 1 << 2;
90+ /// The pattern must match a directory, and not a file.
91+ /// This is equivalent to how it's handled in `gix-glob`
92+ const MUST_BE_DIR = 1 << 3;
3993 }
4094 }
4195
4296 /// Parts of [magic signatures][MagicSignature] which don't stack as they all configure
4397 /// the way path specs are matched.
44−#[derive(Default, PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
98+#[derive(Default, PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
4599 pub enum MatchMode {
46100 /// Expand special characters like `*` similar to how the shell would do it.
47101 ///
@@ -54,7 +108,10 @@ pub enum MatchMode { · 6 unmodified lines
54108 PathAwareGlob,
55109 }
56110
57−/// Parse a git-style pathspec into a [`Pattern`][Pattern].
58−pub fn parse(input: &[u8]) -> Result<Pattern, parse::Error> {
59− Pattern::from_bytes(input)
111+/// Parse a git-style pathspec into a [`Pattern`][Pattern],
112+/// setting the given `default` values in case these aren't specified in `input`.
113+///
114+/// Note that empty [paths](Pattern::path) are allowed here, and generally some processing has to be performed.
115+pub fn parse(input: &[u8], default: parse::Defaults) -> Result<Pattern, parse::Error> {
116+ Pattern::from_bytes(input, default)
60117 }
gix-pathspec/src/parse.rs+52−8
@@ -4,6 +4,21 @@ use bstr::{BStr, BString, ByteSlice, ByteVec}; · 3 unmodified lines
44
55 use crate::{MagicSignature, MatchMode, Pattern};
66
7+/// Default settings for some fields of a [`Pattern`].
8+///
9+/// These can be used to represent `GIT_*_PATHSPECS` environment variables, for example.
10+#[derive(Default, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
11+pub struct Defaults {
12+ /// The default signature.
13+ pub signature: MagicSignature,
14+ /// The default search-mode.
15+ ///
16+ /// Note that even if it's [`MatchMode::Literal`], the pathspecs will be parsed as usual, but matched verbatim afterwards.
17+ pub search_mode: MatchMode,
18+ /// If set, the pathspec will not be parsed but used verbatim. Implies [`MatchMode::Literal`] for `search_mode`.
19+ pub literal: bool,
20+}
21+
722 /// The error returned by [parse()][crate::parse()].
823 #[derive(thiserror::Error, Debug)]
924 #[allow(missing_docs)]
@@ -32,16 +47,31 @@ pub enum Error { · 22 unmodified lines
3247
3348 impl Pattern {
3449 /// Try to parse a path-spec pattern from the given `input` bytes.
35− pub fn from_bytes(input: &[u8]) -> Result<Self, Error> {
50+ pub fn from_bytes(
51+ input: &[u8],
52+ Defaults {
53+ signature,
54+ search_mode,
55+ literal,
56+ }: Defaults,
57+ ) -> Result<Self, Error> {
3658 if input.is_empty() {
3759 return Err(Error::EmptyString);
3860 }
61+ if literal {
62+ return Ok(Self::from_literal(input, signature));
63+ }
64+ if input.as_bstr() == ":" {
65+ return Ok(Pattern {
66+ nil: true,
67+ ..Default::default()
68+ });
69+ }
3970
4071 let mut p = Pattern {
41− path: BString::default(),
42− signature: MagicSignature::empty(),
43− search_mode: MatchMode::ShellGlob,
44− attributes: Vec::new(),
72+ signature,
73+ search_mode,
74+ ..Default::default()
4575 };
4676
4777 let mut cursor = 0;
@@ -54,9 +84,25 @@ impl Pattern { · 6 unmodified lines
5484 }
5585 }
5686
57− p.path = BString::from(&input[cursor..]);
87+ let mut path = &input[cursor..];
88+ if path.last() == Some(&b'/') {
89+ p.signature |= MagicSignature::MUST_BE_DIR;
90+ path = &path[..path.len() - 1];
91+ }
92+ p.path = path.into();
5893 Ok(p)
5994 }
95+
96+ /// Take `input` literally without parsing anything. This will also set our mode to `literal` to allow this pathspec to match `input` verbatim, and
97+ /// use `default_signature` as magic signature.
98+ pub fn from_literal(input: &[u8], default_signature: MagicSignature) -> Self {
99+ Pattern {
100+ path: input.into(),
101+ signature: default_signature,
102+ search_mode: MatchMode::Literal,
103+ ..Default::default()
104+ }
105+ }
60106 }
61107
62108 fn parse_short_keywords(input: &[u8], cursor: &mut usize) -> Result<MagicSignature, Error> {
@@ -90,8 +136,6 @@ fn parse_long_keywords(input: &[u8], p: &mut Pattern, cursor: &mut usize) -> Res · 27 unmodified lines
90136 let input = &input[*cursor..end];
91137 *cursor = end + 1;
92138
93− debug_assert_eq!(p.search_mode, MatchMode::default());
94−
95139 if input.is_empty() {
96140 return Ok(());
97141 }
gix-pathspec/src/pattern.rs+192−0
@@ -0,0 +1,192 @@
1+use crate::{normalize, MagicSignature, MatchMode, Pattern};
2+use bstr::{BStr, BString, ByteSlice, ByteVec};
3+use std::path::{Component, Path, PathBuf};
4+
5+/// Access
6+impl Pattern {
7+ /// Returns `true` if this seems to be a pathspec that indicates that 'there is no pathspec'.
8+ ///
9+ /// Note that such a spec is `:`.
10+ pub fn is_nil(&self) -> bool {
11+ self.nil
12+ }
13+
14+ /// Return the prefix-portion of the `path` of this spec, which is a *directory*.
15+ /// It can be empty if there is no prefix.
16+ ///
17+ /// A prefix is effectively the CWD seen as relative to the working tree, and it's assumed to
18+ /// match case-sensitively. This makes it useful for skipping over large portions of input by
19+ /// directly comparing them.
20+ pub fn prefix_directory(&self) -> &BStr {
21+ self.path[..self.prefix_len].as_bstr()
22+ }
23+
24+ /// Return the path of this spec, typically used for matching.
25+ pub fn path(&self) -> &BStr {
26+ self.path.as_ref()
27+ }
28+}
29+
30+/// Mutation
31+impl Pattern {
32+ /// Normalize the pattern's path by assuring it's relative to the root of the working tree, and contains
33+ /// no relative path components. Further, it assures that `/` are used as path separator.
34+ ///
35+ /// If `self.path` is a relative path, it will be put in front of the pattern path if `self.signature` isn't indicating `TOP` already.
36+ /// If `self.path` is an absolute path, we will use `root` to make it worktree relative if possible.
37+ ///
38+ /// `prefix` can be empty, we will still normalize this pathspec to resolve relative path components, and
39+ /// it is assumed not to contain any relative path components, e.g. '', 'a', 'a/b' are valid.
40+ /// `root` is the absolute path to the root of either the worktree or the repository's `git_dir`.
41+ pub fn normalize(&mut self, prefix: &Path, root: &Path) -> Result<&mut Self, normalize::Error> {
42+ fn prefix_components_to_subtract(path: &Path) -> usize {
43+ let parent_component_end_bound = path.components().enumerate().fold(None::<usize>, |acc, (idx, c)| {
44+ matches!(c, Component::ParentDir).then_some(idx + 1).or(acc)
45+ });
46+ let count = path
47+ .components()
48+ .take(parent_component_end_bound.unwrap_or(0))
49+ .map(|c| match c {
50+ Component::ParentDir => 1_isize,
51+ Component::Normal(_) => -1,
52+ _ => 0,
53+ })
54+ .sum::<isize>();
55+ (count > 0).then_some(count as usize).unwrap_or_default()
56+ }
57+
58+ let mut path = gix_path::from_bstr(self.path.as_ref());
59+ let mut num_prefix_components = 0;
60+ let mut was_absolute = false;
61+ if gix_path::is_absolute(path.as_ref()) {
62+ was_absolute = true;
63+ let rela_path = match path.strip_prefix(root) {
64+ Ok(path) => path,
65+ Err(_) => {
66+ return Err(normalize::Error::AbsolutePathOutsideOfWorktree {
67+ path: path.into_owned(),
68+ worktree_path: root.into(),
69+ })
70+ }
71+ };
72+ path = rela_path.to_owned().into();
73+ } else if !prefix.as_os_str().is_empty() && !self.signature.contains(MagicSignature::TOP) {
74+ debug_assert_eq!(
75+ prefix
76+ .components()
77+ .filter(|c| matches!(c, Component::Normal(_)))
78+ .count(),
79+ prefix.components().count(),
80+ "BUG: prefixes must not have relative path components, or calculations here will be wrong so pattern won't match"
81+ );
82+ num_prefix_components = prefix
83+ .components()
84+ .count()
85+ .saturating_sub(prefix_components_to_subtract(path.as_ref()));
86+ path = prefix.join(path).into();
87+ }
88+
89+ let assure_path_cannot_break_out_upwards = Path::new("");
90+ let path = match gix_path::normalize(path.as_ref(), assure_path_cannot_break_out_upwards) {
91+ Some(path) => {
92+ if was_absolute {
93+ num_prefix_components = path.components().count().saturating_sub(
94+ if self.signature.contains(MagicSignature::MUST_BE_DIR) {
95+ 0
96+ } else {
97+ 1
98+ },
99+ );
100+ }
101+ path
102+ }
103+ None => {
104+ return Err(normalize::Error::OutsideOfWorktree {
105+ path: path.into_owned(),
106+ })
107+ }
108+ };
109+
110+ self.path = if path == Path::new(".") {
111+ BString::from(".")
112+ } else {
113+ let cleaned = PathBuf::from_iter(path.components().filter(|c| !matches!(c, Component::CurDir)));
114+ let mut out = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(cleaned)).into_owned();
115+ self.prefix_len = {
116+ if self.signature.contains(MagicSignature::MUST_BE_DIR) {
117+ out.push(b'/');
118+ }
119+ let len = out
120+ .find_iter(b"/")
121+ .take(num_prefix_components)
122+ .last()
123+ .unwrap_or_default();
124+ if self.signature.contains(MagicSignature::MUST_BE_DIR) {
125+ out.pop();
126+ }
127+ len
128+ };
129+ out
130+ };
131+
132+ Ok(self)
133+ }
134+}
135+
136+/// Access
137+impl Pattern {
138+ /// Return `true` if this pathspec is negated, which means it will exclude an item from the result set instead of including it.
139+ pub fn is_excluded(&self) -> bool {
140+ self.signature.contains(MagicSignature::EXCLUDE)
141+ }
142+
143+ /// Translate ourselves to a long display format, that when parsed back will yield the same pattern.
144+ ///
145+ /// Note that the
146+ pub fn to_bstring(&self) -> BString {
147+ if self.is_nil() {
148+ ":".into()
149+ } else {
150+ let mut buf: BString = ":(".into();
151+ if self.signature.contains(MagicSignature::TOP) {
152+ buf.push_str("top,");
153+ }
154+ if self.signature.contains(MagicSignature::EXCLUDE) {
155+ buf.push_str("exclude,");
156+ }
157+ if self.signature.contains(MagicSignature::ICASE) {
158+ buf.push_str("icase,");
159+ }
160+ match self.search_mode {
161+ MatchMode::ShellGlob => {}
162+ MatchMode::Literal => buf.push_str("literal,"),
163+ MatchMode::PathAwareGlob => buf.push_str("glob,"),
164+ }
165+ if self.attributes.is_empty() {
166+ if buf.last() == Some(&b',') {
167+ buf.pop();
168+ }
169+ } else {
170+ buf.push_str("attr:");
171+ for attr in &self.attributes {
172+ let attr = attr.as_ref().to_string().replace(',', "\\,");
173+ buf.push_str(&attr);
174+ buf.push(b' ');
175+ }
176+ buf.pop(); // trailing ' '
177+ }
178+ buf.push(b')');
179+ buf.extend_from_slice(&self.path);
180+ if self.signature.contains(MagicSignature::MUST_BE_DIR) {
181+ buf.push(b'/');
182+ }
183+ buf
184+ }
185+ }
186+}
187+
188+impl std::fmt::Display for Pattern {
189+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190+ self.to_bstring().fmt(f)
191+ }
192+}
gix-pathspec/src/search/init.rs+134−0
@@ -0,0 +1,134 @@
1+use crate::search::Spec;
2+use crate::{MagicSignature, Pattern, Search};
3+use std::path::Path;
4+
5+/// Create a new specification to support matches from `pathspec`, [normalizing](Pattern::normalize()) it with `prefix` and `root`.
6+fn mapping_from_pattern(
7+ mut pathspec: Pattern,
8+ prefix: &Path,
9+ root: &Path,
10+ sequence_number: usize,
11+) -> Result<gix_glob::search::pattern::Mapping<Spec>, crate::normalize::Error> {
12+ pathspec.normalize(prefix, root)?;
13+ let mut match_all = pathspec.is_nil();
14+ let glob = {
15+ let mut g = gix_glob::Pattern::from_bytes_without_negation(&pathspec.path).unwrap_or_else(|| {
16+ match_all = true;
17+ // This pattern is setup to match literally as all-whitespace.
18+ gix_glob::Pattern {
19+ text: pathspec.path.clone(),
20+ mode: gix_glob::pattern::Mode::empty(),
21+ first_wildcard_pos: None,
22+ }
23+ });
24+ g.mode |= gix_glob::pattern::Mode::ABSOLUTE;
25+ if pathspec.signature.contains(MagicSignature::MUST_BE_DIR) {
26+ g.mode |= gix_glob::pattern::Mode::MUST_BE_DIR;
27+ }
28+ g
29+ };
30+
31+ Ok(gix_glob::search::pattern::Mapping {
32+ pattern: glob,
33+ value: Spec {
34+ attrs_match: {
35+ (!pathspec.attributes.is_empty()).then(|| {
36+ let mut out = gix_attributes::search::Outcome::default();
37+ out.initialize_with_selection(
38+ &Default::default(),
39+ pathspec.attributes.iter().map(|a| a.name.as_str()),
40+ );
41+ out
42+ })
43+ },
44+ pattern: pathspec,
45+ },
46+ sequence_number,
47+ })
48+}
49+
50+fn common_prefix_len(patterns: &[gix_glob::search::pattern::Mapping<Spec>]) -> usize {
51+ let len = patterns
52+ .iter()
53+ .filter(|p| !p.value.pattern.is_excluded())
54+ .map(|p| {
55+ if p.value.pattern.signature.contains(MagicSignature::ICASE) {
56+ p.value.pattern.prefix_len
57+ } else {
58+ p.pattern.first_wildcard_pos.unwrap_or(p.pattern.text.len())
59+ }
60+ })
61+ .min()
62+ .unwrap_or_default();
63+
64+ if len == 0 {
65+ return 0;
66+ }
67+
68+ let mut max_len = len;
69+ let base = &patterns[0].value.pattern.path;
70+ for path in patterns[1..]
71+ .iter()
72+ .filter(|p| !p.value.pattern.is_excluded())
73+ .map(|p| &p.value.pattern.path)
74+ {
75+ for (idx, (a, b)) in base[..max_len].iter().zip(path[..max_len].iter()).enumerate() {
76+ if *a != *b {
77+ max_len = idx;
78+ break;
79+ }
80+ }
81+ }
82+ max_len
83+}
84+
85+/// Lifecycle
86+impl Search {
87+ /// Create a search from ready-made `pathspecs`, and [normalize](Pattern::normalize()) them with `prefix` and `root`.
88+ /// `root` is the absolute path to the worktree root, if available, or the `git_dir` in case of bare repositories.
89+ /// If `pathspecs` doesn't yield any pattern, we will match everything automatically. If `prefix` is also provided and not empty,
90+ /// an artificial pattern will be added to yield all.
91+ pub fn from_specs(
92+ pathspecs: impl IntoIterator<Item = Pattern>,
93+ prefix: Option<&std::path::Path>,
94+ root: &std::path::Path,
95+ ) -> Result<Self, crate::normalize::Error> {
96+ let prefix = prefix.unwrap_or(std::path::Path::new(""));
97+ let mut patterns = pathspecs
98+ .into_iter()
99+ .enumerate()
100+ .map(|(idx, pattern)| mapping_from_pattern(pattern, prefix, root, idx))
101+ .collect::<Result<Vec<_>, _>>()?;
102+
103+ if patterns.is_empty() && !prefix.as_os_str().is_empty() {
104+ patterns.push(mapping_from_pattern(
105+ Pattern::from_literal(&[], MagicSignature::MUST_BE_DIR),
106+ prefix,
107+ root,
108+ 0,
109+ )?);
110+ }
111+
112+ // Excludes should always happen first so we know a match is authoritative (otherwise we could find a non-excluding match first).
113+ patterns.sort_by(|a, b| {
114+ a.value
115+ .pattern
116+ .is_excluded()
117+ .cmp(&b.value.pattern.is_excluded())
118+ .reverse()
119+ });
120+
121+ let common_prefix_len = common_prefix_len(&patterns);
122+ Ok(Search {
123+ all_patterns_are_excluded: patterns.iter().all(|s| s.value.pattern.is_excluded()),
124+ patterns,
125+ source: None,
126+ common_prefix_len,
127+ })
128+ }
129+
130+ /// Obtain ownership of the normalized pathspec patterns that were used for the search.
131+ pub fn into_patterns(self) -> impl Iterator<Item = Pattern> {
132+ self.patterns.into_iter().map(|p| p.value.pattern)
133+ }
134+}
gix-pathspec/src/search/matching.rs+147−0
@@ -0,0 +1,147 @@
1+use crate::search::{Match, Spec};
2+use crate::{MagicSignature, MatchMode, Pattern, Search};
3+use bstr::{BStr, BString, ByteSlice};
4+use gix_glob::pattern::Case;
5+
6+impl Search {
7+ /// Return the first [`Match`] of `relative_path`, or `None`.
8+ /// `is_dir` is true if `relative_path` is a directory.
9+ /// `attributes` is called as `attributes(relative_path, case, is_dir, outcome) -> has_match` to obtain for attributes for `relative_path`, if
10+ /// the underlying pathspec defined an attribute filter, to be stored in `outcome`, returning true if there was a match.
11+ /// All attributes of the pathspec have to be present in the defined value for the pathspec to match.
12+ ///
13+ /// Note that `relative_path` is expected to be starting at the same root as is assumed for this pattern, see [`Pattern::normalize()`].
14+ /// Further, empty searches match everything, as if `:` was provided.
15+ ///
16+ /// ### Deviation
17+ ///
18+ /// The case-sensivity of the attribute match is controlled by the sensitivity of the pathspec, instead of being based on the
19+ /// case folding settings of the repository. That way we assure that the matching is consistent.
20+ /// Higher-level crates should control this default case folding of pathspecs when instantiating them, which is when they can
21+ /// set it to match the repository setting for more natural behaviour when, for instance, adding files to a repository:
22+ /// as it stands, on a case-insensitive file system, `touch File && git add file` will not add the file, but also not error.
23+ pub fn pattern_matching_relative_path<'a>(
24+ &mut self,
25+ relative_path: impl Into<&'a BStr>,
26+ is_dir: Option<bool>,
27+ mut attributes: impl FnMut(&BStr, Case, bool, &mut gix_attributes::search::Outcome) -> bool,
28+ ) -> Option<Match<'_>> {
29+ let relative_path = relative_path.into();
30+ let basename_not_important = None;
31+ if relative_path
32+ .get(..self.common_prefix_len)
33+ .map_or(true, |rela_path_prefix| rela_path_prefix != self.common_prefix())
34+ {
35+ return None;
36+ }
37+
38+ let is_dir = is_dir.unwrap_or(false);
39+ let patterns_len = self.patterns.len();
40+ let res = self.patterns.iter_mut().find_map(|mapping| {
41+ let ignore_case = mapping.value.pattern.signature.contains(MagicSignature::ICASE);
42+ let prefix = mapping.value.pattern.prefix_directory();
43+ if ignore_case && !prefix.is_empty() {
44+ let pattern_requirement_is_met = relative_path.get(prefix.len()).map_or_else(|| is_dir, |b| *b == b'/');
45+ if !pattern_requirement_is_met
46+ || relative_path.get(..prefix.len()).map(ByteSlice::as_bstr) != Some(prefix)
47+ {
48+ return None;
49+ }
50+ }
51+
52+ let case = if ignore_case { Case::Fold } else { Case::Sensitive };
53+ let mut is_match = mapping.value.pattern.is_nil() || mapping.value.pattern.path.is_empty();
54+ if !is_match {
55+ is_match = if mapping.pattern.first_wildcard_pos.is_none() {
56+ match_verbatim(mapping, relative_path, is_dir, case)
57+ } else {
58+ let wildmatch_mode = match mapping.value.pattern.search_mode {
59+ MatchMode::ShellGlob => Some(gix_glob::wildmatch::Mode::empty()),
60+ MatchMode::Literal => None,
61+ MatchMode::PathAwareGlob => Some(gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL),
62+ };
63+ match wildmatch_mode {
64+ Some(wildmatch_mode) => {
65+ let is_match = mapping.pattern.matches_repo_relative_path(
66+ relative_path,
67+ basename_not_important,
68+ Some(is_dir),
69+ case,
70+ wildmatch_mode,
71+ );
72+ if !is_match {
73+ match_verbatim(mapping, relative_path, is_dir, case)
74+ } else {
75+ true
76+ }
77+ }
78+ None => match_verbatim(mapping, relative_path, is_dir, case),
79+ }
80+ }
81+ }
82+
83+ if let Some(attrs) = mapping.value.attrs_match.as_mut() {
84+ if !attributes(relative_path, Case::Sensitive, is_dir, attrs) {
85+ // we have attrs, but it didn't match any
86+ return None;
87+ }
88+ for (actual, expected) in attrs.iter_selected().zip(mapping.value.pattern.attributes.iter()) {
89+ if actual.assignment != expected.as_ref() {
90+ return None;
91+ }
92+ }
93+ }
94+
95+ is_match.then_some(Match {
96+ pattern: &mapping.value.pattern,
97+ sequence_number: mapping.sequence_number,
98+ })
99+ });
100+
101+ if res.is_none() && self.all_patterns_are_excluded {
102+ static MATCH_ALL_STAND_IN: Pattern = Pattern {
103+ path: BString::new(Vec::new()),
104+ signature: MagicSignature::empty(),
105+ search_mode: MatchMode::ShellGlob,
106+ attributes: Vec::new(),
107+ prefix_len: 0,
108+ nil: true,
109+ };
110+ Some(Match {
111+ pattern: &MATCH_ALL_STAND_IN,
112+ sequence_number: patterns_len,
113+ })
114+ } else {
115+ res
116+ }
117+ }
118+}
119+
120+fn match_verbatim(
121+ mapping: &gix_glob::search::pattern::Mapping<Spec>,
122+ relative_path: &BStr,
123+ is_dir: bool,
124+ case: Case,
125+) -> bool {
126+ let pattern_len = mapping.value.pattern.path.len();
127+ let mut relative_path_ends_with_slash_at_pattern_len = false;
128+ let match_is_allowed = relative_path.get(pattern_len).map_or_else(
129+ || relative_path.len() == pattern_len,
130+ |b| {
131+ relative_path_ends_with_slash_at_pattern_len = *b == b'/';
132+ relative_path_ends_with_slash_at_pattern_len
133+ },
134+ );
135+ let pattern_requirement_is_met = !mapping.pattern.mode.contains(gix_glob::pattern::Mode::MUST_BE_DIR)
136+ || (relative_path_ends_with_slash_at_pattern_len || is_dir);
137+
138+ if match_is_allowed && pattern_requirement_is_met {
139+ let dir_or_file = &relative_path[..mapping.value.pattern.path.len()];
140+ match case {
141+ Case::Sensitive => mapping.value.pattern.path == dir_or_file,
142+ Case::Fold => mapping.value.pattern.path.eq_ignore_ascii_case(dir_or_file),
143+ }
144+ } else {
145+ false
146+ }
147+}
gix-pathspec/src/search/mod.rs+47−0
@@ -0,0 +1,47 @@
1+use crate::{Pattern, Search};
2+use bstr::{BStr, ByteSlice};
3+
4+/// Describes a matching pattern within a search for ignored paths.
5+#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
6+pub struct Match<'a> {
7+ /// The matching search specification, which contains the pathspec as well.
8+ pub pattern: &'a Pattern,
9+ /// 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`.
10+ pub sequence_number: usize,
11+}
12+
13+mod init;
14+
15+impl Match<'_> {
16+ /// Return `true` if the pathspec that matched was negative, which excludes this item from the set.
17+ pub fn is_excluded(&self) -> bool {
18+ self.pattern.is_excluded()
19+ }
20+}
21+
22+/// Access
23+impl Search {
24+ /// Return an iterator over the patterns that participate in the search.
25+ pub fn patterns(&self) -> impl Iterator<Item = &Pattern> + '_ {
26+ self.patterns.iter().map(|m| &m.value.pattern)
27+ }
28+
29+ /// Return the portion of the prefix among all of the pathspecs involved in this search, or an empty string if
30+ /// there is none. It doesn't have to end at a directory boundary though, nor does it denote a directory.
31+ ///
32+ /// Note that the common_prefix can be matched case-insensitively, which makes it useful to skip large portions of input.
33+ /// Further, excluded pathspecs don't participate which makes this common prefix inclusive.
34+ pub fn common_prefix(&self) -> &BStr {
35+ self.patterns
36+ .get(0)
37+ .map_or("".into(), |m| m.value.pattern.path[..self.common_prefix_len].as_bstr())
38+ }
39+}
40+
41+#[derive(Default, Clone, Debug)]
42+pub(crate) struct Spec {
43+ pub pattern: Pattern,
44+ pub attrs_match: Option<gix_attributes::search::Outcome>,
45+}
46+
47+mod matching;
gix-pathspec/tests/fixtures/generated-archives/generate_pathspec_baseline.tar.xz+0−3
@@ -1,3 +0,0 @@
1−version https://git-lfs.github.com/spec/v1
2−oid sha256:505e0eed20808737e0f3f8b993e54e3b2b9b9d124e1615a003336ce34c60b939
3−size 9484
gix-pathspec/tests/fixtures/generated-archives/match_baseline.tar.xz+3−0
@@ -0,0 +1,3 @@
1+version https://git-lfs.github.com/spec/v1
2+oid sha256:0ba46801dfab65bcc77c8ce0dc9144d77fd142be847f3340af6ae8fcba44c82d
3+size 16928
gix-pathspec/tests/fixtures/generated-archives/match_baseline_dirs.tar.xz+3−0
@@ -0,0 +1,3 @@
1+version https://git-lfs.github.com/spec/v1
2+oid sha256:343ad9b362536abf88d6fc4519b6532e53e53f8a1b94436c9ec3f88b6874e4df
3+size 15972
gix-pathspec/tests/fixtures/generated-archives/match_baseline_files.tar.xz+3−0
@@ -0,0 +1,3 @@
1+version https://git-lfs.github.com/spec/v1
2+oid sha256:b0c722ab08de2ab63b9f222747cf898f5b6bb6b76ec55ccd176bab3b854ef7bb
3+size 10700
gix-pathspec/tests/fixtures/generated-archives/parse_baseline.tar.xz+3−0
@@ -0,0 +1,3 @@
1+version https://git-lfs.github.com/spec/v1
2+oid sha256:123da727b33802a3a2dc80e948b07cbf133c2d66129693f4896cba2f6b92b02f
3+size 9528
gix-pathspec/tests/fixtures/match_baseline_dirs.sh+86−0
@@ -0,0 +1,86 @@
1+#!/bin/bash
2+set -eu -o pipefail
3+
4+git init;
5+git init sub
6+(cd sub
7+ : >empty
8+ git add empty
9+ git commit -m init-for-submodule
10+)
11+
12+git init parent
13+(cd parent
14+ function baseline() {
15+ local args=""
16+ local specs=""
17+
18+ for arg in "$@"; do
19+ if [[ $arg == *"+"* ]]; then
20+ echo "BUG: Argument '$arg' contains a space - we use that for separating pathspecs right now" >&2
21+ exit 1
22+ fi
23+ args="$args -c submodule.active=$arg"
24+ specs="${specs}+${arg}"
25+ done
26+
27+ {
28+ echo "$specs"
29+ git $args submodule
30+ echo -n ';'
31+ } >> baseline.git
32+ }
33+
34+ for p in a bb dir/b dir/bb dir/nested/c cc; do
35+ git submodule add ../sub $p
36+ git config --unset submodule.$p.active
37+ done
38+ git commit -m "init"
39+
40+ git submodule > paths
41+
42+ baseline ':'
43+ baseline ':!'
44+ baseline 'a'
45+ baseline ':(icase)A'
46+ baseline ':(icase,exclude)A'
47+ baseline ':(icase,exclude)*/B*'
48+ baseline ':(icase,exclude)*/B?'
49+ baseline 'di'
50+ baseline 'di?'
51+ baseline 'di?/'
52+ baseline 'dir*'
53+ baseline 'dir/*'
54+ baseline ':(glob)dir*'
55+ baseline ':(glob,icase,exclude)dir*'
56+ baseline ':(glob)dir/*'
57+ baseline 'dir'
58+ baseline 'dir/'
59+ baseline ':(literal)dir'
60+ baseline ':(literal)dir/'
61+ baseline 'dir/nested'
62+ baseline 'dir/nested/'
63+ baseline ':(exclude)dir/'
64+ baseline ':(icase)DIR'
65+ baseline ':(icase)DIR/'
66+ baseline ':!a'
67+ baseline ':' ':!bb'
68+ baseline ':!bb'
69+ baseline 'a/'
70+ baseline 'bb'
71+ baseline 'dir/b'
72+ baseline 'dir/b/'
73+ # ["dir/b"] == []
74+ baseline '*/b/' git-inconsistency
75+ baseline '*b'
76+ baseline '*b*'
77+ baseline '*b?'
78+ baseline '*/b'
79+ baseline '*/b?'
80+ baseline '*/b*'
81+ baseline '*c'
82+ baseline '*/c'
83+ baseline ':(glob)**/c'
84+ baseline ':(glob)**/c?'
85+ baseline ':(glob)**/c*'
86+)
gix-pathspec/tests/fixtures/match_baseline_files.sh+94−0
@@ -0,0 +1,94 @@
1+#!/bin/bash
2+set -eu -o pipefail
3+
4+git init;
5+
6+function baseline() {
7+ local specs=""
8+ for arg in "$@"; do
9+ if [[ $arg == *"+"* ]]; then
10+ echo "BUG: Argument '$arg' contains a space - we use that for separating pathspecs right now" >&2
11+ exit 1
12+ fi
13+ specs="${specs}+${arg}"
14+ done
15+
16+ {
17+ echo "$specs"
18+ git ls-files "$@"
19+ echo -n ';'
20+ } >> baseline.git
21+}
22+
23+: >goo
24+: >'g[o][o]'
25+: >bar
26+echo 'goo a !b c=v -d' > .gitattributes
27+mkdir sub && :>sub/bar
28+git add . && git commit -m init
29+# this is to avoid issues on windows, which might not be able to manifest these files.
30+git -c core.protectNTFS=false update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" "g*"
31+git update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" "!a"
32+for p in bar bAr BAR foo/bar foo/bAr foo/BAR fOo/bar fOo/bAr fOo/BAR FOO/bar FOO/bAr FOO/BAR; do
33+ git -c core.ignoreCase=false update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" "$p"
34+done
35+git -c core.ignoreCase=false update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" " " # 4 x space
36+git -c core.ignoreCase=false update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" " hi " # 2 x space hi 2 x space
37+
38+git ls-files > paths
39+
40+baseline ':(attr:a)goo'
41+baseline ':(attr:a)Goo'
42+baseline ':(icase,attr:a)Goo'
43+baseline ':(attr:!b)goo'
44+baseline ':(attr:c=v)goo'
45+baseline ':(attr:-d)goo'
46+baseline ':(attr:a !b c=v -d)goo'
47+baseline ':(icase,attr:a !b c=v -d)GOO'
48+baseline ':(attr:a !b c=v -d)g*'
49+baseline ':(attr:none)goo'
50+baseline ':(literal)g*'
51+baseline 'sub/'
52+baseline 'sub'
53+baseline 'sub/*'
54+baseline 'sub*'
55+baseline ':(literal)g*'
56+baseline ':(glob)g*'
57+baseline ':(exclude,literal)g*'
58+baseline 'g*'
59+baseline ':(exclude)g*'
60+baseline ':(literal)?*'
61+baseline ':(exclude,literal)?*'
62+baseline '?*'
63+baseline ':(exclude)?*'
64+baseline 'g[o][o]'
65+# ["g[o][o]", "goo"] == ["g[o][o]"]
66+baseline ':(icase)G[O][o]' git-inconsistency
67+baseline ':(literal)g[o][o]'
68+baseline ':(literal,icase)G[o][O]'
69+baseline ':(glob)g[o][o]'
70+# ["g[o][o]", "goo"] == ["g[o][o]"]
71+baseline ':(glob,icase)g[o][O]' git-inconsistency
72+baseline ':(glob)**/bar'
73+baseline ':(literal)**/bar'
74+baseline '**/bar'
75+baseline '*/bar'
76+baseline ':(glob)*bar'
77+baseline ':(glob)**bar'
78+baseline '*bar'
79+baseline '*bar*'
80+baseline 'bar'
81+baseline 'bar/'
82+baseline 'sub/bar/'
83+baseline 'sub/bar'
84+baseline '!a'
85+baseline '?a'
86+baseline 'foo/'
87+baseline 'foo'
88+baseline 'foo/*'
89+baseline 'foo*'
90+baseline ':(icase)foo/'
91+baseline ':(icase)foo'
92+baseline ':(icase)foo/*'
93+baseline ':(icase)foo*'
94+baseline ':(icase)foo/bar'
gix-pathspec/tests/fixtures/parse_baseline.shrenamed from gix-pathspec/tests/fixtures/generate_pathspec_baseline.sh+13−0
@@ -28,6 +28,7 @@ baseline ':!^(exclude,exclude)' · 27 unmodified lines
2828
2929 # empty_signatures
3030 baseline '.'
31+baseline ':'
3132 baseline 'some/path'
3233 baseline ':some/path'
3334 baseline ':()some/path'
@@ -47,8 +48,11 @@ baseline ':! some/path' · 13 unmodified lines
4748
4849 # short_signatures
4950 baseline ':/some/path'
51+baseline '://some/path'
5052 baseline ':^some/path'
53+baseline ':^^some/path'
5154 baseline ':!some/path'
55+baseline ':!!some/path'
5256 baseline ':/!some/path'
5357 baseline ':!/^/:some/path'
5458
@@ -143,3 +147,12 @@ baseline ':(top' · 88 unmodified lines
143147
144148 # glob_and_literal_keywords_present
145149 baseline ':(glob,literal)some/path'
150+# trailing slash
151+baseline ':(glob,literal)some/path/'
152+baseline 'some/path/'
153+baseline 'path/'
154+
155+baseline 'a/b/'
156+baseline 'a/'
157+baseline '!a'
158+baseline '\!a'
gix-pathspec/tests/normalize/mod.rs+116−0
@@ -0,0 +1,116 @@
1+use std::path::Path;
2+
3+#[test]
4+fn removes_relative_path_components() -> crate::Result {
5+ for (input_path, expected_path, expected_prefix) in [
6+ ("c", "a/b/c", "a/b"),
7+ ("../c", "a/c", "a"),
8+ ("../b/c", "a/b/c", "a"), // this is a feature - prefix components once consumed by .. are lost. Important as paths can contain globs
9+ ("../*c/d", "a/*c/d", "a"),
10+ ("../../c/d", "c/d", ""),
11+ ("../../c/d/", "c/d", ""),
12+ ("./c", "a/b/c", "a/b"),
13+ ("../../c", "c", ""),
14+ ("../..", ".", ""),
15+ ("../././c", "a/c", "a"),
16+ ("././/./c", "a/b/c", "a/b"),
17+ ("././/./c/", "a/b/c", "a/b"),
18+ ("././/./../c/d/", "a/c/d", "a"),
19+ ] {
20+ let spec = normalized_spec(input_path, "a/b", "")?;
21+ assert_eq!(spec.path(), expected_path);
22+ assert_eq!(
23+ spec.prefix_directory(),
24+ expected_prefix,
25+ "{input_path} -> {expected_path}"
26+ );
27+ }
28+ Ok(())
29+}
30+
31+#[test]
32+fn single_dot_is_special_and_directory_is_implied_without_trailing_slash() -> crate::Result {
33+ for (input_path, expected) in [(".", "."), ("./", ".")] {
34+ let spec = normalized_spec(input_path, "", "/repo")?;
35+ assert_eq!(spec.path(), expected);
36+ assert_eq!(spec.prefix_directory(), "");
37+ }
38+ Ok(())
39+}
40+
41+#[test]
42+fn absolute_path_made_relative() -> crate::Result {
43+ for (input_path, expected, prefix_dir) in [
44+ ("/repo/a", "a", ""),
45+ ("/repo/a/..//.///b", "b", ""),
46+ ("/repo/a/", "a", "a"),
47+ ("/repo/*/", "*", "*"),
48+ ("/repo/a/b", "a/b", "a"),
49+ ("/repo/*/b", "*/b", "*"), // we assume literal paths if specs are absolute
50+ ("/repo/a/*/", "a/*", "a/*"),
51+ ("/repo/a/b/", "a/b", "a/b"),
52+ ("/repo/a/b/*", "a/b/*", "a/b"),
53+ ("/repo/a/b/c/..", "a/b", "a"),
54+ ] {
55+ let spec = normalized_spec(input_path, "", "/repo")?;
56+ assert_eq!(spec.path(), expected);
57+ assert_eq!(spec.prefix_directory(), prefix_dir, "{input_path}");
58+ }
59+ Ok(())
60+}
61+
62+#[test]
63+fn relative_top_patterns_ignore_the_prefix() -> crate::Result {
64+ let spec = normalized_spec(":(top)c", "a/b", "")?;
65+ assert_eq!(spec.path(), "c");
66+ assert_eq!(spec.prefix_directory(), "");
67+ Ok(())
68+}
69+
70+#[test]
71+fn absolute_top_patterns_ignore_the_prefix_but_are_made_relative() -> crate::Result {
72+ let spec = normalized_spec(":(top)/a/b", "prefix-ignored", "/a")?;
73+ assert_eq!(spec.path(), "b");
74+ assert_eq!(spec.prefix_directory(), "");
75+ Ok(())
76+}
77+
78+#[test]
79+fn relative_path_breaks_out_of_working_tree() {
80+ let err = normalized_spec("../a", "", "").unwrap_err();
81+ assert_eq!(err.to_string(), "The path '../a' leaves the repository");
82+ let err = normalized_spec("../../b", "a", "").unwrap_err();
83+ assert_eq!(
84+ err.to_string(),
85+ format!(
86+ "The path '{}' leaves the repository",
87+ if cfg!(windows) { "a\\../../b" } else { "a/../../b" }
88+ )
89+ );
90+}
91+
92+#[test]
93+fn absolute_path_breaks_out_of_working_tree() {
94+ let err = normalized_spec("/path/to/repo/..///./a", "", "/path/to/repo").unwrap_err();
95+ assert_eq!(err.to_string(), "The path '..///./a' leaves the repository");
96+ let err = normalized_spec("/path/to/repo/../../../dev", "", "/path/to/repo").unwrap_err();
97+ assert_eq!(err.to_string(), "The path '../../../dev' leaves the repository");
98+}
99+
100+#[test]
101+fn absolute_path_escapes_worktree() {
102+ assert_eq!(
103+ normalized_spec("/dev", "", "/path/to/repo").unwrap_err().to_string(),
104+ "The path '/dev' is not inside of the worktree '/path/to/repo'"
105+ );
106+}
107+
108+fn normalized_spec(
109+ path: &str,
110+ prefix: &str,
111+ root: &str,
112+) -> Result<gix_pathspec::Pattern, gix_pathspec::normalize::Error> {
113+ let mut spec = gix_pathspec::parse(path.as_bytes(), Default::default()).expect("valid");
114+ spec.normalize(Path::new(prefix), Path::new(root))?;
115+ Ok(spec)
116+}
gix-pathspec/tests/parse/invalid.rs+152−0
@@ -0,0 +1,152 @@
1+use gix_pathspec::parse::Error;
2+
3+use crate::parse::check_against_baseline;
4+
5+#[test]
6+fn empty_input() {
7+ let input = "";
8+
9+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
10+
11+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
12+ assert!(output.is_err());
13+ assert!(matches!(output.unwrap_err(), Error::EmptyString));
14+}
15+
16+#[test]
17+fn invalid_short_signatures() {
18+ let inputs = vec![
19+ ":\"()", ":#()", ":%()", ":&()", ":'()", ":,()", ":-()", ":;()", ":<()", ":=()", ":>()", ":@()", ":_()",
20+ ":`()", ":~()",
21+ ];
22+
23+ for input in inputs.into_iter() {
24+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
25+
26+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
27+ assert!(output.is_err());
28+ assert!(matches!(output.unwrap_err(), Error::Unimplemented { .. }));
29+ }
30+}
31+
32+#[test]
33+fn invalid_keywords() {
34+ let inputs = vec![
35+ ":( )some/path",
36+ ":(tp)some/path",
37+ ":(top, exclude)some/path",
38+ ":(top,exclude,icse)some/path",
39+ ];
40+
41+ for input in inputs.into_iter() {
42+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
43+
44+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
45+ assert!(output.is_err());
46+ assert!(matches!(output.unwrap_err(), Error::InvalidKeyword { .. }));
47+ }
48+}
49+
50+#[test]
51+fn invalid_attributes() {
52+ let inputs = vec![
53+ ":(attr:+invalidAttr)some/path",
54+ ":(attr:validAttr +invalidAttr)some/path",
55+ ":(attr:+invalidAttr,attr:valid)some/path",
56+ r":(attr:inva\lid)some/path",
57+ ];
58+
59+ for input in inputs {
60+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
61+
62+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
63+ assert!(output.is_err(), "This pathspec did not produce an error {input}");
64+ assert!(matches!(output.unwrap_err(), Error::InvalidAttribute { .. }));
65+ }
66+}
67+
68+#[test]
69+fn invalid_attribute_values() {
70+ let inputs = vec![
71+ r":(attr:v=inva#lid)some/path",
72+ r":(attr:v=inva\\lid)some/path",
73+ r":(attr:v=invalid\\)some/path",
74+ r":(attr:v=invalid\#)some/path",
75+ r":(attr:v=inva\=lid)some/path",
76+ r":(attr:a=valid b=inva\#lid)some/path",
77+ ":(attr:v=val��)",
78+ ":(attr:pr=pre��x:,)�",
79+ ];
80+
81+ for input in inputs {
82+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
83+
84+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
85+ assert!(output.is_err(), "This pathspec did not produce an error {input}");
86+ assert!(
87+ matches!(output.unwrap_err(), Error::InvalidAttributeValue { .. }),
88+ "Errors did not match for pathspec: {input}"
89+ );
90+ }
91+}
92+
93+#[test]
94+fn escape_character_at_end_of_attribute_value() {
95+ let inputs = vec![
96+ r":(attr:v=invalid\)some/path",
97+ r":(attr:v=invalid\ )some/path",
98+ r":(attr:v=invalid\ valid)some/path",
99+ ];
100+
101+ for input in inputs {
102+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
103+
104+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
105+ assert!(output.is_err(), "This pathspec did not produce an error {input}");
106+ assert!(matches!(output.unwrap_err(), Error::TrailingEscapeCharacter));
107+ }
108+}
109+
110+#[test]
111+fn empty_attribute_specification() {
112+ let input = ":(attr:)";
113+
114+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
115+
116+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
117+ assert!(output.is_err());
118+ assert!(matches!(output.unwrap_err(), Error::EmptyAttribute));
119+}
120+
121+#[test]
122+fn multiple_attribute_specifications() {
123+ let input = ":(attr:one,attr:two)some/path";
124+
125+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
126+
127+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
128+ assert!(output.is_err());
129+ assert!(matches!(output.unwrap_err(), Error::MultipleAttributeSpecifications));
130+}
131+
132+#[test]
133+fn missing_parentheses() {
134+ let input = ":(top";
135+
136+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
137+
138+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
139+ assert!(output.is_err());
140+ assert!(matches!(output.unwrap_err(), Error::MissingClosingParenthesis { .. }));
141+}
142+
143+#[test]
144+fn glob_and_literal_keywords_present() {
145+ let input = ":(glob,literal)some/path";
146+
147+ assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
148+
149+ let output = gix_pathspec::parse(input.as_bytes(), Default::default());
150+ assert!(output.is_err());
151+ assert!(matches!(output.unwrap_err(), Error::IncompatibleSearchModes));
152+}
gix-pathspec/tests/parse/mod.rs+93−0
@@ -0,0 +1,93 @@
1+use std::collections::HashMap;
2+
3+use bstr::{BStr, BString, ByteSlice};
4+use gix_attributes::State;
5+use gix_pathspec::{MagicSignature, MatchMode, Pattern};
6+use once_cell::sync::Lazy;
7+
8+#[test]
9+fn baseline() {
10+ for (pattern, exit_code) in BASELINE.iter() {
11+ let res = gix_pathspec::parse(pattern, Default::default());
12+ assert_eq!(
13+ res.is_ok(),
14+ *exit_code == 0,
15+ "{pattern:?} disagrees with baseline: {res:?}"
16+ );
17+ if let Ok(pat) = res {
18+ let actual = pat.to_bstring();
19+ assert_eq!(
20+ pat,
21+ gix_pathspec::parse(actual.as_ref(), Default::default()).expect("still valid"),
22+ "{pattern} != {actual}: display must roundtrip into actual pattern"
23+ );
24+ }
25+ let p = gix_pathspec::Pattern::from_literal(pattern, Default::default());
26+ assert!(matches!(p.search_mode, MatchMode::Literal));
27+ }
28+}
29+
30+mod invalid;
31+mod valid;
32+
33+/// A way to specify expectations more easily by simplifying assignments.
34+#[derive(Debug, Clone, PartialEq, Eq)]
35+struct NormalizedPattern {
36+ path: BString,
37+ signature: MagicSignature,
38+ search_mode: MatchMode,
39+ attributes: Vec<(BString, State)>,
40+}
41+
42+impl From<Pattern> for NormalizedPattern {
43+ fn from(p: Pattern) -> Self {
44+ NormalizedPattern {
45+ path: p.path().to_owned(),
46+ signature: p.signature,
47+ search_mode: p.search_mode,
48+ attributes: p
49+ .attributes
50+ .into_iter()
51+ .map(|attr| (attr.name.as_str().into(), attr.state))
52+ .collect(),
53+ }
54+ }
55+}
56+
57+static BASELINE: Lazy<HashMap<BString, usize>> = Lazy::new(|| {
58+ let base = gix_testtools::scripted_fixture_read_only("parse_baseline.sh").unwrap();
59+
60+ (|| -> crate::Result<_> {
61+ let mut map = HashMap::new();
62+ let baseline = std::fs::read(base.join("baseline.git"))?;
63+ let mut lines = baseline.lines();
64+ while let Some(spec) = lines.next() {
65+ let exit_code = lines.next().expect("two lines per baseline").to_str()?.parse()?;
66+ map.insert(spec.into(), exit_code);
67+ }
68+ Ok(map)
69+ })()
70+ .unwrap()
71+});
72+
73+fn check_valid_inputs<'a>(inputs: impl IntoIterator<Item = (&'a str, NormalizedPattern)>) {
74+ for (input, expected) in inputs.into_iter() {
75+ assert!(
76+ check_against_baseline(input),
77+ "This pathspec is invalid in git: {input}"
78+ );
79+
80+ let pattern = gix_pathspec::parse(input.as_bytes(), Default::default())
81+ .unwrap_or_else(|_| panic!("parsing should not fail with pathspec {input}"));
82+ let pattern: NormalizedPattern = pattern.into();
83+ assert_eq!(pattern, expected, "while checking input: \"{input}\"");
84+ }
85+}
86+
87+fn check_against_baseline(pathspec: &str) -> bool {
88+ let key: &BStr = pathspec.into();
89+ let base = BASELINE
90+ .get(key)
91+ .unwrap_or_else(|| panic!("missing baseline for pathspec: {pathspec:?}"));
92+ *base == 0
93+}
gix-pathspec/tests/parse/valid.rs+329−0
@@ -0,0 +1,329 @@
1+use gix_attributes::State;
2+use gix_pathspec::{MagicSignature, MatchMode};
3+
4+use crate::parse::{check_against_baseline, check_valid_inputs, NormalizedPattern};
5+
6+#[test]
7+fn repeated_matcher_keywords() {
8+ let input = vec![
9+ (":(glob,glob)", pat_with_search_mode(MatchMode::PathAwareGlob)),
10+ (":(literal,literal)", pat_with_search_mode(MatchMode::Literal)),
11+ (":(top,top)", pat_with_sig(MagicSignature::TOP)),
12+ (":(icase,icase)", pat_with_sig(MagicSignature::ICASE)),
13+ (":(attr,attr)", pat_with_attrs(vec![])),
14+ (":!^(exclude,exclude)", pat_with_sig(MagicSignature::EXCLUDE)),
15+ ];
16+
17+ check_valid_inputs(input);
18+}
19+
20+#[test]
21+fn glob_negations_are_always_literal() {
22+ check_valid_inputs([("!a", pat_with_path("!a")), ("\\!a", pat_with_path("\\!a"))]);
23+}
24+
25+#[test]
26+fn literal_default_prevents_parsing() {
27+ let pattern = gix_pathspec::parse(
28+ ":".as_bytes(),
29+ gix_pathspec::parse::Defaults {
30+ signature: MagicSignature::EXCLUDE,
31+ search_mode: MatchMode::PathAwareGlob,
32+ literal: true,
33+ },
34+ )
35+ .expect("valid");
36+ assert!(!pattern.is_nil());
37+ assert_eq!(pattern.path(), ":");
38+ assert!(matches!(pattern.search_mode, MatchMode::Literal));
39+
40+ let input = ":(literal)f[o][o]";
41+ let pattern = gix_pathspec::parse(
42+ input.as_bytes(),
43+ gix_pathspec::parse::Defaults {
44+ signature: MagicSignature::TOP,
45+ search_mode: MatchMode::Literal,
46+ literal: true,
47+ },
48+ )
49+ .expect("valid");
50+ assert_eq!(pattern.path(), input, "no parsing happens at all");
51+ assert!(matches!(pattern.search_mode, MatchMode::Literal));
52+
53+ let pattern = gix_pathspec::parse(
54+ input.as_bytes(),
55+ gix_pathspec::parse::Defaults {
56+ signature: MagicSignature::TOP,
57+ search_mode: MatchMode::Literal,
58+ literal: false,
59+ },
60+ )
61+ .expect("valid");
62+ assert_eq!(pattern.path(), "f[o][o]", "in literal default mode, we still parse");
63+ assert!(matches!(pattern.search_mode, MatchMode::Literal));
64+}
65+
66+#[test]
67+fn there_is_no_pathspec_pathspec() {
68+ check_against_baseline(":");
69+ let pattern = gix_pathspec::parse(":".as_bytes(), Default::default()).expect("valid");
70+ assert!(pattern.is_nil());
71+
72+ let actual: NormalizedPattern = pattern.into();
73+ assert_eq!(actual, pat_with_path(""));
74+
75+ let pattern = gix_pathspec::parse(
76+ ":".as_bytes(),
77+ gix_pathspec::parse::Defaults {
78+ signature: MagicSignature::EXCLUDE,
79+ search_mode: MatchMode::PathAwareGlob,
80+ literal: false,
81+ },
82+ )
83+ .expect("valid");
84+ assert!(pattern.is_nil());
85+}
86+
87+#[test]
88+fn defaults_are_used() -> crate::Result {
89+ let defaults = gix_pathspec::parse::Defaults {
90+ signature: MagicSignature::EXCLUDE,
91+ search_mode: MatchMode::Literal,
92+ literal: false,
93+ };
94+ let p = gix_pathspec::parse(".".as_bytes(), defaults)?;
95+ assert_eq!(p.path(), ".");
96+ assert_eq!(p.signature, defaults.signature);
97+ assert_eq!(p.search_mode, defaults.search_mode);
98+ assert!(p.attributes.is_empty());
99+ assert!(!p.is_nil());
100+ Ok(())
101+}
102+
103+#[test]
104+fn empty_signatures() {
105+ let inputs = vec![
106+ (".", pat_with_path(".")),
107+ ("some/path", pat_with_path("some/path")),
108+ (":some/path", pat_with_path("some/path")),
109+ (":()some/path", pat_with_path("some/path")),
110+ ("::some/path", pat_with_path("some/path")),
111+ (":::some/path", pat_with_path(":some/path")),
112+ (":():some/path", pat_with_path(":some/path")),
113+ ];
114+
115+ check_valid_inputs(inputs)
116+}
117+
118+#[test]
119+fn whitespace_in_pathspec() {
120+ let inputs = vec![
121+ (" some/path", pat_with_path(" some/path")),
122+ ("some/ path", pat_with_path("some/ path")),
123+ ("some/path ", pat_with_path("some/path ")),
124+ (": some/path", pat_with_path(" some/path")),
125+ (": !some/path", pat_with_path(" !some/path")),
126+ (": :some/path", pat_with_path(" :some/path")),
127+ (": ()some/path", pat_with_path(" ()some/path")),
128+ (
129+ ":! some/path",
130+ pat_with_path_and_sig(" some/path", MagicSignature::EXCLUDE),
131+ ),
132+ (
133+ ":!!some/path",
134+ pat_with_path_and_sig("some/path", MagicSignature::EXCLUDE),
135+ ),
136+ ];
137+
138+ check_valid_inputs(inputs)
139+}
140+
141+#[test]
142+fn short_signatures() {
143+ let inputs = vec![
144+ (":/some/path", pat_with_path_and_sig("some/path", MagicSignature::TOP)),
145+ (
146+ ":^some/path",
147+ pat_with_path_and_sig("some/path", MagicSignature::EXCLUDE),
148+ ),
149+ (
150+ ":!some/path",
151+ pat_with_path_and_sig("some/path", MagicSignature::EXCLUDE),
152+ ),
153+ (
154+ ":/!some/path",
155+ pat_with_path_and_sig("some/path", MagicSignature::TOP | MagicSignature::EXCLUDE),
156+ ),
157+ (
158+ ":!/^/:some/path",
159+ pat_with_path_and_sig("some/path", MagicSignature::TOP | MagicSignature::EXCLUDE),
160+ ),
161+ ];
162+
163+ check_valid_inputs(inputs)
164+}
165+
166+#[test]
167+fn trailing_slash_is_turned_into_magic_signature_and_removed() {
168+ check_valid_inputs([
169+ ("a/b/", pat_with_path_and_sig("a/b", MagicSignature::MUST_BE_DIR)),
170+ ("a/", pat_with_path_and_sig("a", MagicSignature::MUST_BE_DIR)),
171+ ]);
172+}
173+
174+#[test]
175+fn signatures_and_searchmodes() {
176+ let inputs = vec![
177+ (":(top)", pat_with_sig(MagicSignature::TOP)),
178+ (":(icase)", pat_with_sig(MagicSignature::ICASE)),
179+ (":(attr)", pat_with_path("")),
180+ (":(exclude)", pat_with_sig(MagicSignature::EXCLUDE)),
181+ (":(literal)", pat_with_search_mode(MatchMode::Literal)),
182+ (":(glob)", pat_with_search_mode(MatchMode::PathAwareGlob)),
183+ (
184+ ":(top,exclude)",
185+ pat_with_sig(MagicSignature::TOP | MagicSignature::EXCLUDE),
186+ ),
187+ (
188+ ":(icase,literal)",
189+ pat("", MagicSignature::ICASE, MatchMode::Literal, vec![]),
190+ ),
191+ (
192+ ":!(literal)some/*path",
193+ pat("some/*path", MagicSignature::EXCLUDE, MatchMode::Literal, vec![]),
194+ ),
195+ (
196+ ":(top,literal,icase,attr,exclude)some/path",
197+ pat(
198+ "some/path",
199+ MagicSignature::TOP | MagicSignature::EXCLUDE | MagicSignature::ICASE,
200+ MatchMode::Literal,
201+ vec![],
202+ ),
203+ ),
204+ (
205+ ":(top,glob,icase,attr,exclude)some/path",
206+ pat(
207+ "some/path",
208+ MagicSignature::TOP | MagicSignature::EXCLUDE | MagicSignature::ICASE,
209+ MatchMode::PathAwareGlob,
210+ vec![],
211+ ),
212+ ),
213+ ];
214+
215+ check_valid_inputs(inputs);
216+}
217+
218+#[test]
219+fn attributes_in_signature() {
220+ let inputs = vec![
221+ (":(attr:someAttr)", pat_with_attrs(vec![("someAttr", State::Set)])),
222+ (
223+ ":(attr:!someAttr)",
224+ pat_with_attrs(vec![("someAttr", State::Unspecified)]),
225+ ),
226+ (":(attr:-someAttr)", pat_with_attrs(vec![("someAttr", State::Unset)])),
227+ (
228+ ":(attr:someAttr=value)",
229+ pat_with_attrs(vec![("someAttr", State::Value("value".into()))]),
230+ ),
231+ (
232+ ":(attr:a=one b=)",
233+ pat_with_attrs(vec![("a", State::Value("one".into())), ("b", State::Value("".into()))]),
234+ ),
235+ (
236+ ":(attr:a= b=two)",
237+ pat_with_attrs(vec![("a", State::Value("".into())), ("b", State::Value("two".into()))]),
238+ ),
239+ (
240+ ":(attr:a=one b=two)",
241+ pat_with_attrs(vec![
242+ ("a", State::Value("one".into())),
243+ ("b", State::Value("two".into())),
244+ ]),
245+ ),
246+ (
247+ ":(attr:a=one b=two)",
248+ pat_with_attrs(vec![
249+ ("a", State::Value("one".into())),
250+ ("b", State::Value("two".into())),
251+ ]),
252+ ),
253+ (
254+ ":(attr:someAttr anotherAttr)",
255+ pat_with_attrs(vec![("someAttr", State::Set), ("anotherAttr", State::Set)]),
256+ ),
257+ ];
258+
259+ check_valid_inputs(inputs)
260+}
261+
262+#[test]
263+fn attributes_with_escape_chars_in_state_values() {
264+ let inputs = vec![
265+ (
266+ r":(attr:v=one\-)",
267+ pat_with_attrs(vec![("v", State::Value(r"one-".into()))]),
268+ ),
269+ (
270+ r":(attr:v=one\_)",
271+ pat_with_attrs(vec![("v", State::Value(r"one_".into()))]),
272+ ),
273+ (
274+ r":(attr:v=one\,)",
275+ pat_with_attrs(vec![("v", State::Value(r"one,".into()))]),
276+ ),
277+ (
278+ r":(attr:v=one\,two\,three)",
279+ pat_with_attrs(vec![("v", State::Value(r"one,two,three".into()))]),
280+ ),
281+ (
282+ r":(attr:a=\d b= c=\d)",
283+ pat_with_attrs(vec![
284+ ("a", State::Value(r"d".into())),
285+ ("b", State::Value(r"".into())),
286+ ("c", State::Value(r"d".into())),
287+ ]),
288+ ),
289+ ];
290+
291+ check_valid_inputs(inputs)
292+}
293+
294+fn pat_with_path(path: &str) -> NormalizedPattern {
295+ pat_with_path_and_sig(path, MagicSignature::empty())
296+}
297+
298+fn pat_with_path_and_sig(path: &str, signature: MagicSignature) -> NormalizedPattern {
299+ pat(path, signature, MatchMode::ShellGlob, vec![])
300+}
301+
302+fn pat_with_sig(signature: MagicSignature) -> NormalizedPattern {
303+ pat("", signature, MatchMode::ShellGlob, vec![])
304+}
305+
306+fn pat_with_attrs(attrs: Vec<(&'static str, State)>) -> NormalizedPattern {
307+ pat("", MagicSignature::empty(), MatchMode::ShellGlob, attrs)
308+}
309+
310+fn pat_with_search_mode(search_mode: MatchMode) -> NormalizedPattern {
311+ pat("", MagicSignature::empty(), search_mode, vec![])
312+}
313+
314+fn pat(
315+ path: &str,
316+ signature: MagicSignature,
317+ search_mode: MatchMode,
318+ attributes: Vec<(&str, State)>,
319+) -> NormalizedPattern {
320+ NormalizedPattern {
321+ path: path.into(),
322+ signature,
323+ search_mode,
324+ attributes: attributes
325+ .into_iter()
326+ .map(|(attr, state)| (attr.into(), state))
327+ .collect(),
328+ }
329+}
gix-pathspec/tests/pathspec.rs+3−471
@@ -1,473 +1,5 @@
11 pub use gix_testtools::Result;
22
3−mod parse {
4− use std::collections::HashMap;
5−
6− use bstr::{BStr, BString, ByteSlice};
7− use gix_attributes::State;
8− use gix_pathspec::{MagicSignature, MatchMode, Pattern};
9− use once_cell::sync::Lazy;
10−
11− #[derive(Debug, Clone, PartialEq, Eq)]
12− struct PatternForTesting {
13− path: BString,
14− signature: MagicSignature,
15− search_mode: MatchMode,
16− attributes: Vec<(BString, State)>,
17− }
18−
19− impl From<Pattern> for PatternForTesting {
20− fn from(p: Pattern) -> Self {
21− PatternForTesting {
22− path: p.path,
23− signature: p.signature,
24− search_mode: p.search_mode,
25− attributes: p
26− .attributes
27− .into_iter()
28− .map(|attr| (attr.name.as_str().into(), attr.state))
29− .collect(),
30− }
31− }
32− }
33−
34− static BASELINE: Lazy<HashMap<BString, usize>> = Lazy::new(|| {
35− let base = gix_testtools::scripted_fixture_read_only("generate_pathspec_baseline.sh").unwrap();
36−
37− (|| -> crate::Result<_> {
38− let mut map = HashMap::new();
39− let baseline = std::fs::read(base.join("baseline.git"))?;
40− let mut lines = baseline.lines();
41− while let Some(spec) = lines.next() {
42− let exit_code = lines.next().expect("two lines per baseline").to_str()?.parse()?;
43− map.insert(spec.into(), exit_code);
44− }
45− Ok(map)
46− })()
47− .unwrap()
48− });
49−
50− #[test]
51− fn baseline() {
52− for (pattern, exit_code) in BASELINE.iter() {
53− let res = gix_pathspec::parse(pattern);
54− assert_eq!(
55− res.is_ok(),
56− *exit_code == 0,
57− "{pattern:?} disagrees with baseline: {res:?}"
58− )
59− }
60− }
61−
62− mod succeed {
63− use gix_attributes::State;
64− use gix_pathspec::{MagicSignature, MatchMode};
65−
66− use crate::parse::{
67− check_valid_inputs, pat, pat_with_attrs, pat_with_path, pat_with_path_and_sig, pat_with_search_mode,
68− pat_with_sig,
69− };
70−
71− #[test]
72− fn there_is_no_pathspec_pathspec() {
73− check_valid_inputs(Some((":", pat_with_attrs(vec![]))));
74− }
75−
76− #[test]
77− fn repeated_matcher_keywords() {
78− let input = vec![
79− (":(glob,glob)", pat_with_search_mode(MatchMode::PathAwareGlob)),
80− (":(literal,literal)", pat_with_search_mode(MatchMode::Literal)),
81− (":(top,top)", pat_with_sig(MagicSignature::TOP)),
82− (":(icase,icase)", pat_with_sig(MagicSignature::ICASE)),
83− (":(attr,attr)", pat_with_attrs(vec![])),
84− (":!^(exclude,exclude)", pat_with_sig(MagicSignature::EXCLUDE)),
85− ];
86−
87− check_valid_inputs(input);
88− }
89−
90− #[test]
91− fn empty_signatures() {
92− let inputs = vec![
93− (".", pat_with_path(".")),
94− ("some/path", pat_with_path("some/path")),
95− (":some/path", pat_with_path("some/path")),
96− (":()some/path", pat_with_path("some/path")),
97− ("::some/path", pat_with_path("some/path")),
98− (":::some/path", pat_with_path(":some/path")),
99− (":():some/path", pat_with_path(":some/path")),
100− ];
101−
102− check_valid_inputs(inputs)
103− }
104−
105− #[test]
106− fn whitespace_in_pathspec() {
107− let inputs = vec![
108− (" some/path", pat_with_path(" some/path")),
109− ("some/ path", pat_with_path("some/ path")),
110− ("some/path ", pat_with_path("some/path ")),
111− (": some/path", pat_with_path(" some/path")),
112− (": !some/path", pat_with_path(" !some/path")),
113− (": :some/path", pat_with_path(" :some/path")),
114− (": ()some/path", pat_with_path(" ()some/path")),
115− (
116− ":! some/path",
117− pat_with_path_and_sig(" some/path", MagicSignature::EXCLUDE),
118− ),
119− ];
120−
121− check_valid_inputs(inputs)
122− }
123−
124− #[test]
125− fn short_signatures() {
126− let inputs = vec![
127− (":/some/path", pat_with_path_and_sig("some/path", MagicSignature::TOP)),
128− (
129− ":^some/path",
130− pat_with_path_and_sig("some/path", MagicSignature::EXCLUDE),
131− ),
132− (
133− ":!some/path",
134− pat_with_path_and_sig("some/path", MagicSignature::EXCLUDE),
135− ),
136− (
137− ":/!some/path",
138− pat_with_path_and_sig("some/path", MagicSignature::TOP | MagicSignature::EXCLUDE),
139− ),
140− (
141− ":!/^/:some/path",
142− pat_with_path_and_sig("some/path", MagicSignature::TOP | MagicSignature::EXCLUDE),
143− ),
144− ];
145−
146− check_valid_inputs(inputs)
147− }
148−
149− #[test]
150− fn signatures_and_searchmodes() {
151− let inputs = vec![
152− (":(top)", pat_with_sig(MagicSignature::TOP)),
153− (":(icase)", pat_with_sig(MagicSignature::ICASE)),
154− (":(attr)", pat_with_path("")),
155− (":(exclude)", pat_with_sig(MagicSignature::EXCLUDE)),
156− (":(literal)", pat_with_search_mode(MatchMode::Literal)),
157− (":(glob)", pat_with_search_mode(MatchMode::PathAwareGlob)),
158− (
159− ":(top,exclude)",
160− pat_with_sig(MagicSignature::TOP | MagicSignature::EXCLUDE),
161− ),
162− (
163− ":(icase,literal)",
164− pat("", MagicSignature::ICASE, MatchMode::Literal, vec![]),
165− ),
166− (
167− ":!(literal)some/*path",
168− pat("some/*path", MagicSignature::EXCLUDE, MatchMode::Literal, vec![]),
169− ),
170− (
171− ":(top,literal,icase,attr,exclude)some/path",
172− pat("some/path", MagicSignature::all(), MatchMode::Literal, vec![]),
173− ),
174− (
175− ":(top,glob,icase,attr,exclude)some/path",
176− pat("some/path", MagicSignature::all(), MatchMode::PathAwareGlob, vec![]),
177− ),
178− ];
179−
180− check_valid_inputs(inputs);
181− }
182−
183− #[test]
184− fn attributes_in_signature() {
185− let inputs = vec![
186− (":(attr:someAttr)", pat_with_attrs(vec![("someAttr", State::Set)])),
187− (
188− ":(attr:!someAttr)",
189− pat_with_attrs(vec![("someAttr", State::Unspecified)]),
190− ),
191− (":(attr:-someAttr)", pat_with_attrs(vec![("someAttr", State::Unset)])),
192− (
193− ":(attr:someAttr=value)",
194− pat_with_attrs(vec![("someAttr", State::Value("value".into()))]),
195− ),
196− (
197− ":(attr:a=one b=)",
198− pat_with_attrs(vec![("a", State::Value("one".into())), ("b", State::Value("".into()))]),
199− ),
200− (
201− ":(attr:a= b=two)",
202− pat_with_attrs(vec![("a", State::Value("".into())), ("b", State::Value("two".into()))]),
203− ),
204− (
205− ":(attr:a=one b=two)",
206− pat_with_attrs(vec![
207− ("a", State::Value("one".into())),
208− ("b", State::Value("two".into())),
209− ]),
210− ),
211− (
212− ":(attr:a=one b=two)",
213− pat_with_attrs(vec![
214− ("a", State::Value("one".into())),
215− ("b", State::Value("two".into())),
216− ]),
217− ),
218− (
219− ":(attr:someAttr anotherAttr)",
220− pat_with_attrs(vec![("someAttr", State::Set), ("anotherAttr", State::Set)]),
221− ),
222− ];
223−
224− check_valid_inputs(inputs)
225− }
226−
227− #[test]
228− fn attributes_with_escape_chars_in_state_values() {
229− let inputs = vec![
230− (
231− r":(attr:v=one\-)",
232− pat_with_attrs(vec![("v", State::Value(r"one-".into()))]),
233− ),
234− (
235− r":(attr:v=one\_)",
236− pat_with_attrs(vec![("v", State::Value(r"one_".into()))]),
237− ),
238− (
239− r":(attr:v=one\,)",
240− pat_with_attrs(vec![("v", State::Value(r"one,".into()))]),
241− ),
242− (
243− r":(attr:v=one\,two\,three)",
244− pat_with_attrs(vec![("v", State::Value(r"one,two,three".into()))]),
245− ),
246− (
247− r":(attr:a=\d b= c=\d)",
248− pat_with_attrs(vec![
249− ("a", State::Value(r"d".into())),
250− ("b", State::Value(r"".into())),
251− ("c", State::Value(r"d".into())),
252− ]),
253− ),
254− ];
255−
256− check_valid_inputs(inputs)
257− }
258− }
259−
260− mod fail {
261− use gix_pathspec::parse::Error;
262−
263− use crate::parse::check_against_baseline;
264−
265− #[test]
266− fn empty_input() {
267− let input = "";
268−
269− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
270−
271− let output = gix_pathspec::parse(input.as_bytes());
272− assert!(output.is_err());
273− assert!(matches!(output.unwrap_err(), Error::EmptyString));
274− }
275−
276− #[test]
277− fn invalid_short_signatures() {
278− let inputs = vec![
279− ":\"()", ":#()", ":%()", ":&()", ":'()", ":,()", ":-()", ":;()", ":<()", ":=()", ":>()", ":@()",
280− ":_()", ":`()", ":~()",
281− ];
282−
283− for input in inputs.into_iter() {
284− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
285−
286− let output = gix_pathspec::parse(input.as_bytes());
287− assert!(output.is_err());
288− assert!(matches!(output.unwrap_err(), Error::Unimplemented { .. }));
289− }
290− }
291−
292− #[test]
293− fn invalid_keywords() {
294− let inputs = vec![
295− ":( )some/path",
296− ":(tp)some/path",
297− ":(top, exclude)some/path",
298− ":(top,exclude,icse)some/path",
299− ];
300−
301− for input in inputs.into_iter() {
302− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
303−
304− let output = gix_pathspec::parse(input.as_bytes());
305− assert!(output.is_err());
306− assert!(matches!(output.unwrap_err(), Error::InvalidKeyword { .. }));
307− }
308− }
309−
310− #[test]
311− fn invalid_attributes() {
312− let inputs = vec![
313− ":(attr:+invalidAttr)some/path",
314− ":(attr:validAttr +invalidAttr)some/path",
315− ":(attr:+invalidAttr,attr:valid)some/path",
316− r":(attr:inva\lid)some/path",
317− ];
318−
319− for input in inputs {
320− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
321−
322− let output = gix_pathspec::parse(input.as_bytes());
323− assert!(output.is_err(), "This pathspec did not produce an error {input}");
324− assert!(matches!(output.unwrap_err(), Error::InvalidAttribute { .. }));
325− }
326− }
327−
328− #[test]
329− fn invalid_attribute_values() {
330− let inputs = vec![
331− r":(attr:v=inva#lid)some/path",
332− r":(attr:v=inva\\lid)some/path",
333− r":(attr:v=invalid\\)some/path",
334− r":(attr:v=invalid\#)some/path",
335− r":(attr:v=inva\=lid)some/path",
336− r":(attr:a=valid b=inva\#lid)some/path",
337− ":(attr:v=val��)",
338− ":(attr:pr=pre��x:,)�",
339− ];
340−
341− for input in inputs {
342− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
343−
344− let output = gix_pathspec::parse(input.as_bytes());
345− assert!(output.is_err(), "This pathspec did not produce an error {input}");
346− assert!(
347− matches!(output.unwrap_err(), Error::InvalidAttributeValue { .. }),
348− "Errors did not match for pathspec: {input}"
349− );
350− }
351− }
352−
353− #[test]
354− fn escape_character_at_end_of_attribute_value() {
355− let inputs = vec![
356− r":(attr:v=invalid\)some/path",
357− r":(attr:v=invalid\ )some/path",
358− r":(attr:v=invalid\ valid)some/path",
359− ];
360−
361− for input in inputs {
362− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
363−
364− let output = gix_pathspec::parse(input.as_bytes());
365− assert!(output.is_err(), "This pathspec did not produce an error {input}");
366− assert!(matches!(output.unwrap_err(), Error::TrailingEscapeCharacter));
367− }
368− }
369−
370− #[test]
371− fn empty_attribute_specification() {
372− let input = ":(attr:)";
373−
374− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
375−
376− let output = gix_pathspec::parse(input.as_bytes());
377− assert!(output.is_err());
378− assert!(matches!(output.unwrap_err(), Error::EmptyAttribute));
379− }
380−
381− #[test]
382− fn multiple_attribute_specifications() {
383− let input = ":(attr:one,attr:two)some/path";
384−
385− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
386−
387− let output = gix_pathspec::parse(input.as_bytes());
388− assert!(output.is_err());
389− assert!(matches!(output.unwrap_err(), Error::MultipleAttributeSpecifications));
390− }
391−
392− #[test]
393− fn missing_parentheses() {
394− let input = ":(top";
395−
396− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
397−
398− let output = gix_pathspec::parse(input.as_bytes());
399− assert!(output.is_err());
400− assert!(matches!(output.unwrap_err(), Error::MissingClosingParenthesis { .. }));
401− }
402−
403− #[test]
404− fn glob_and_literal_keywords_present() {
405− let input = ":(glob,literal)some/path";
406−
407− assert!(!check_against_baseline(input), "This pathspec is valid in git: {input}");
408−
409− let output = gix_pathspec::parse(input.as_bytes());
410− assert!(output.is_err());
411− assert!(matches!(output.unwrap_err(), Error::IncompatibleSearchModes));
412− }
413− }
414−
415− fn check_valid_inputs<'a>(inputs: impl IntoIterator<Item = (&'a str, PatternForTesting)>) {
416− for (input, expected) in inputs.into_iter() {
417− assert!(
418− check_against_baseline(input),
419− "This pathspec is invalid in git: {input}"
420− );
421−
422− let pattern: PatternForTesting = gix_pathspec::parse(input.as_bytes())
423− .unwrap_or_else(|_| panic!("parsing should not fail with pathspec {input}"))
424− .into();
425− assert_eq!(pattern, expected, "while checking input: \"{input}\"");
426− }
427− }
428−
429− fn pat_with_path(path: &str) -> PatternForTesting {
430− pat_with_path_and_sig(path, MagicSignature::empty())
431− }
432−
433− fn pat_with_path_and_sig(path: &str, signature: MagicSignature) -> PatternForTesting {
434− pat(path, signature, MatchMode::ShellGlob, vec![])
435− }
436−
437− fn pat_with_sig(signature: MagicSignature) -> PatternForTesting {
438− pat("", signature, MatchMode::ShellGlob, vec![])
439− }
440−
441− fn pat_with_attrs(attrs: Vec<(&'static str, State)>) -> PatternForTesting {
442− pat("", MagicSignature::empty(), MatchMode::ShellGlob, attrs)
443− }
444−
445− fn pat_with_search_mode(search_mode: MatchMode) -> PatternForTesting {
446− pat("", MagicSignature::empty(), search_mode, vec![])
447− }
448−
449− fn pat(
450− path: &str,
451− signature: MagicSignature,
452− search_mode: MatchMode,
453− attributes: Vec<(&str, State)>,
454− ) -> PatternForTesting {
455− PatternForTesting {
456− path: path.into(),
457− signature,
458− search_mode,
459− attributes: attributes
460− .into_iter()
461− .map(|(attr, state)| (attr.into(), state))
462− .collect(),
463− }
464− }
465−
466− fn check_against_baseline(pathspec: &str) -> bool {
467− let key: &BStr = pathspec.into();
468− let base = BASELINE
469− .get(key)
470− .unwrap_or_else(|| panic!("missing baseline for pathspec: {pathspec:?}"));
471− *base == 0
472− }
473−}
3+mod normalize;
4+mod parse;
5+mod search;
gix-pathspec/tests/search/mod.rs+257−0
@@ -0,0 +1,257 @@
1+use std::path::Path;
2+
3+#[test]
4+fn directories() -> crate::Result {
5+ baseline::run("directory", true, baseline::directories)
6+}
7+
8+#[test]
9+fn no_pathspecs_match_everything() -> crate::Result {
10+ let mut search = gix_pathspec::Search::from_specs([], None, Path::new(""))?;
11+ assert_eq!(search.patterns().count(), 0, "nothing artificial is added");
12+ let m = search
13+ .pattern_matching_relative_path("hello", None, |_, _, _, _| unreachable!("must not be called"))
14+ .expect("matches");
15+ assert_eq!(m.pattern.prefix_directory(), "", "there is no prefix as none was given");
16+
17+ Ok(())
18+}
19+
20+#[test]
21+fn no_pathspecs_respect_prefix() -> crate::Result {
22+ let mut search = gix_pathspec::Search::from_specs([], Some(Path::new("a")), Path::new(""))?;
23+ assert_eq!(
24+ search.patterns().count(),
25+ 1,
26+ "we get an artificial pattern to get the prefix"
27+ );
28+ assert!(
29+ search
30+ .pattern_matching_relative_path("hello", None, |_, _, _, _| unreachable!("must not be called"))
31+ .is_none(),
32+ "not the right prefix"
33+ );
34+ let m = search
35+ .pattern_matching_relative_path("a/b", None, |_, _, _, _| unreachable!("must not be called"))
36+ .expect("match");
37+ assert_eq!(
38+ m.pattern.prefix_directory(),
39+ "a",
40+ "the prefix directory matched verbatim"
41+ );
42+
43+ Ok(())
44+}
45+
46+#[test]
47+fn prefixes_are_always_case_insensitive() -> crate::Result {
48+ let path = gix_testtools::scripted_fixture_read_only("match_baseline_files.sh")?.join("paths");
49+ let items = baseline::parse_paths(path)?;
50+
51+ for (spec, prefix, common_prefix, expected) in [
52+ (":(icase)bar", "FOO", "FOO", &["FOO/BAR", "FOO/bAr", "FOO/bar"] as &[_]),
53+ (":(icase)bar", "F", "F", &[]),
54+ (":(icase)bar", "FO", "FO", &[]),
55+ (":(icase)../bar", "fOo", "", &["BAR", "bAr", "bar"]),
56+ ("../bar", "fOo", "bar", &["bar"]),
57+ (" ", "", " ", &[" "]), // whitespace can match verbatim
58+ (" hi*", "", " hi", &[" hi "]), // whitespace can match with globs as well
59+ (":(icase)../bar", "fO", "", &["BAR", "bAr", "bar"]), // prefixes are virtual, and don't have to exist at all.
60+ (
61+ ":(icase)../foo/bar",
62+ "FOO",
63+ "",
64+ &[
65+ "FOO/BAR", "FOO/bAr", "FOO/bar", "fOo/BAR", "fOo/bAr", "fOo/bar", "foo/BAR", "foo/bAr", "foo/bar",
66+ ],
67+ ),
68+ ("../foo/bar", "FOO", "foo/bar", &["foo/bar"]),
69+ (
70+ ":(icase)../foo/../fOo/bar",
71+ "FOO",
72+ "",
73+ &[
74+ "FOO/BAR", "FOO/bAr", "FOO/bar", "fOo/BAR", "fOo/bAr", "fOo/bar", "foo/BAR", "foo/bAr", "foo/bar",
75+ ],
76+ ),
77+ ("../foo/../fOo/BAR", "FOO", "fOo/BAR", &["fOo/BAR"]),
78+ ] {
79+ let mut search = gix_pathspec::Search::from_specs(
80+ gix_pathspec::parse(spec.as_bytes(), Default::default()),
81+ Some(Path::new(prefix)),
82+ Path::new(""),
83+ )?;
84+ assert_eq!(search.common_prefix(), common_prefix, "{spec} {prefix}");
85+ let actual: Vec<_> = items
86+ .iter()
87+ .filter(|relative_path| {
88+ search
89+ .pattern_matching_relative_path(relative_path.as_str(), Some(false), |_, _, _, _| false)
90+ .is_some()
91+ })
92+ .collect();
93+ assert_eq!(actual, expected, "{spec} {prefix}");
94+ }
95+ Ok(())
96+}
97+
98+#[test]
99+fn common_prefix() -> crate::Result {
100+ for (specs, prefix, expected) in [
101+ (&["foo/bar", ":(icase)foo/bar"] as &[_], None, ""),
102+ (&["foo/bar", "foo"], None, "foo"),
103+ (&["foo/bar/baz", "foo/bar/"], None, "foo/bar"), // directory trailing slashes are ignored, but that prefix shouldn't care anyway
104+ (&[":(icase)bar", ":(icase)bart"], Some("foo"), "foo"), // only case-sensitive portions count
105+ (&["bar", "bart"], Some("foo"), "foo/bar"), // otherwise everything that matches counts
106+ (&["bar", "bart", "ba"], Some("foo"), "foo/ba"),
107+ ] {
108+ let search = gix_pathspec::Search::from_specs(
109+ specs
110+ .iter()
111+ .map(|s| gix_pathspec::parse(s.as_bytes(), Default::default()).expect("valid")),
112+ prefix.map(Path::new),
113+ Path::new(""),
114+ )?;
115+ assert_eq!(search.common_prefix(), expected, "{specs:?} {prefix:?}");
116+ }
117+ Ok(())
118+}
119+
120+#[test]
121+fn files() -> crate::Result {
122+ baseline::run("file", false, baseline::files)
123+}
124+
125+mod baseline {
126+ use bstr::{BString, ByteSlice};
127+ use std::path::{Path, PathBuf};
128+
129+ pub fn run(
130+ name: &str,
131+ items_are_dirs: bool,
132+ init: impl FnOnce() -> crate::Result<(PathBuf, Vec<String>, Vec<Expected>)>,
133+ ) -> crate::Result {
134+ let (root, items, expected) = init()?;
135+ let mut collection = Default::default();
136+ let attrs =
137+ gix_attributes::Search::new_globals(Some(root.join(".gitattributes")), &mut Vec::new(), &mut collection)?;
138+ let tests = expected.len();
139+ for expected in expected {
140+ let mut search = gix_pathspec::Search::from_specs(expected.pathspecs, None, Path::new(""))?;
141+ let actual: Vec<_> = items
142+ .iter()
143+ .filter(|path| {
144+ search
145+ .pattern_matching_relative_path(
146+ path.as_str(),
147+ Some(items_are_dirs),
148+ |rela_path, case, is_dir, out| {
149+ out.initialize(&collection);
150+ attrs.pattern_matching_relative_path(rela_path, case, Some(is_dir), out)
151+ },
152+ )
153+ .map_or(false, |m| !m.is_excluded())
154+ })
155+ .cloned()
156+ .collect();
157+ let matches_expectation = actual == expected.matches;
158+ assert_eq!(
159+ matches_expectation,
160+ expected.is_consistent,
161+ "{} - {actual:?} == {:?}",
162+ search.patterns().map(|p| format!("{p}")).collect::<Vec<_>>().join(", "),
163+ expected.matches
164+ );
165+ }
166+ eprintln!("{tests} {name} matches OK");
167+ Ok(())
168+ }
169+
170+ #[derive(Debug)]
171+ pub struct Expected {
172+ pub pathspecs: Vec<gix_pathspec::Pattern>,
173+ pub matches: Vec<String>,
174+ /// If true, this means that the baseline is different from what we get, and that our solution is consistent with the rules.
175+ pub is_consistent: bool,
176+ }
177+
178+ pub fn parse_paths(path: PathBuf) -> std::io::Result<Vec<String>> {
179+ let buf = std::fs::read(path)?;
180+ Ok(buf.lines().map(BString::from).map(|s| s.to_string()).collect())
181+ }
182+
183+ fn parse_blocks(input: &[u8], parse_matches: impl Fn(&[u8]) -> Vec<String>) -> Vec<Expected> {
184+ input
185+ .split(|b| *b == b';')
186+ .filter(|b| !b.is_empty())
187+ .map(move |block| {
188+ let mut lines = block.lines();
189+ let mut is_inconsistent = false;
190+ let pathspecs = lines
191+ .next()
192+ .expect("pathspec")
193+ .split(|b| *b == b'+')
194+ .filter(|spec| {
195+ is_inconsistent = spec.as_bstr() == "git-inconsistency";
196+ !is_inconsistent
197+ })
198+ .filter_map(|s| (!s.trim().is_empty()).then(|| s.trim()))
199+ .map(|pathspec| gix_pathspec::parse(pathspec, Default::default()).expect("valid pathspec"))
200+ .collect();
201+ Expected {
202+ pathspecs,
203+ matches: parse_matches(lines.as_bytes()),
204+ is_consistent: !is_inconsistent,
205+ }
206+ })
207+ .collect()
208+ }
209+
210+ mod submodule {
211+ use bstr::ByteSlice;
212+
213+ pub fn matches_from_status(input: &[u8]) -> impl Iterator<Item = (bool, String)> + '_ {
214+ input.lines().map(|line| {
215+ let matches = line[0] == b' ';
216+ assert_eq!(!matches, line[0] == b'-');
217+ let mut tokens = line[1..].split(|b| *b == b' ').skip(1);
218+ let path = tokens.next().expect("path").to_str().expect("valid UTF-8");
219+ (matches, path.to_owned())
220+ })
221+ }
222+
223+ pub fn parse_expected(input: &[u8]) -> Vec<super::Expected> {
224+ super::parse_blocks(input, |block| {
225+ matches_from_status(block)
226+ .filter_map(|(matches, module_path)| matches.then_some(module_path))
227+ .collect()
228+ })
229+ }
230+ }
231+
232+ mod files {
233+ use bstr::{BString, ByteSlice};
234+ pub fn parse_expected(input: &[u8]) -> Vec<super::Expected> {
235+ super::parse_blocks(input, |block| {
236+ block.lines().map(BString::from).map(|s| s.to_string()).collect()
237+ })
238+ }
239+ }
240+
241+ pub fn directories() -> crate::Result<(PathBuf, Vec<String>, Vec<Expected>)> {
242+ let root = gix_testtools::scripted_fixture_read_only("match_baseline_dirs.sh")?.join("parent");
243+ let buf = std::fs::read(root.join("paths"))?;
244+ let items = submodule::matches_from_status(&buf)
245+ .map(|(_matches, path)| path)
246+ .collect();
247+ let expected = submodule::parse_expected(&std::fs::read(root.join("baseline.git"))?);
248+ Ok((root, items, expected))
249+ }
250+
251+ pub fn files() -> crate::Result<(PathBuf, Vec<String>, Vec<Expected>)> {
252+ let root = gix_testtools::scripted_fixture_read_only("match_baseline_files.sh")?;
253+ let items = parse_paths(root.join("paths"))?;
254+ let expected = files::parse_expected(&std::fs::read(root.join("baseline.git"))?);
255+ Ok((root, items, expected))
256+ }
257+}
gix/Cargo.toml+1−0
@@ -164,6 +164,7 @@ gix-index = { version = "^0.22.0", path = "../gix-index" } · 163 unmodified lines
164164 gix-worktree = { version = "^0.24.0", path = "../gix-worktree" }
165165 gix-hashtable = { version = "^0.2.4", path = "../gix-hashtable" }
166166 gix-commitgraph = { version = "^0.18.2", path = "../gix-commitgraph" }
167+gix-pathspec = { version = "^0.1.0", path = "../gix-pathspec" }
167168
168169 gix-worktree-stream = { version = "^0.3.0", path = "../gix-worktree-stream", optional = true }
169170 gix-archive = { version = "^0.3.0", path = "../gix-archive", default-features = false, optional = true }
gix/src/lib.rs+1−0
@@ -99,6 +99,7 @@ pub use gix_negotiate as negotiate; · 98 unmodified lines
9999 pub use gix_object as objs;
100100 pub use gix_object::bstr;
101101 pub use gix_odb as odb;
102+pub use gix_pathspec as pathspec;
102103 pub use gix_prompt as prompt;
103104 #[cfg(feature = "gix-protocol")]
104105 pub use gix_protocol as protocol;
gix/src/repository/location.rs+23−20
@@ -45,27 +45,30 @@ impl crate::Repository { · 44 unmodified lines
4545 /// Note that there may be `None` if there is no work tree, even though the `PathBuf` will be empty
4646 /// if the CWD is at the root of the work tree.
4747 // TODO: tests, details - there is a lot about environment variables to change things around.
48− pub fn prefix(&self) -> Option<std::io::Result<PathBuf>> {
49− self.work_tree.as_ref().map(|root| {
50− std::env::current_dir().and_then(|cwd| {
51− gix_path::realpath_opts(root, &cwd, MAX_SYMLINKS)
52− .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
53− .and_then(|root| {
54− cwd.strip_prefix(&root)
55− .map_err(|_| {
56− std::io::Error::new(
57− std::io::ErrorKind::Other,
58− format!(
59− "CWD '{}' isn't within the work tree '{}'",
60− cwd.display(),
61− root.display()
62− ),
63− )
64− })
65− .map(ToOwned::to_owned)
66− })
48+ pub fn prefix(&self) -> std::io::Result<Option<PathBuf>> {
49+ self.work_tree
50+ .as_ref()
51+ .map(|root| {
52+ std::env::current_dir().and_then(|cwd| {
53+ gix_path::realpath_opts(root, &cwd, MAX_SYMLINKS)
54+ .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
55+ .and_then(|root| {
56+ cwd.strip_prefix(&root)
57+ .map_err(|_| {
58+ std::io::Error::new(
59+ std::io::ErrorKind::Other,
60+ format!(
61+ "CWD '{}' isn't within the work tree '{}'",
62+ cwd.display(),
63+ root.display()
64+ ),
65+ )
66+ })
67+ .map(ToOwned::to_owned)
68+ })
69+ })
6770 })
68− })
71+ .transpose()
6972 }
7073
7174 /// Return the kind of repository, either bare or one with a work tree.
src/plumbing/main.rs+26−22
@@ -1018,7 +1018,7 @@ pub fn main() -> Result<()> { · 1017 unmodified lines
10181018 ),
10191019 },
10201020 Subcommands::Attributes(cmd) => match cmd {
1021− attributes::Subcommands::Query { statistics, pathspecs } => prepare_and_run(
1021+ attributes::Subcommands::Query { statistics, pathspec } => prepare_and_run(
10221022 "attributes-query",
10231023 trace,
10241024 verbose,
@@ -1029,15 +1029,17 @@ pub fn main() -> Result<()> { · 4 unmodified lines
10291029 use gix::bstr::ByteSlice;
10301030 core::repository::attributes::query(
10311031 repository(Mode::Strict)?,
1032− if pathspecs.is_empty() {
1033− Box::new(
1034− stdin_or_bail()?
1035− .byte_lines()
1036− .filter_map(Result::ok)
1037− .filter_map(|line| gix::path::Spec::from_bytes(line.as_bstr())),
1038− ) as Box<dyn Iterator<Item = gix::path::Spec>>
1032+ if pathspec.is_empty() {
1033+ Box::new(stdin_or_bail()?.byte_lines().filter_map(Result::ok).filter_map(|line| {
1034+ gix::pathspec::parse(
1035+ line.as_bstr(),
1036+ // TODO(pathspec): use `repo` actual global defaults when available (see following as well)
1037+ Default::default(),
1038+ )
1039+ .ok()
1040+ })) as Box<dyn Iterator<Item = gix::pathspec::Pattern>>
10391041 } else {
1040− Box::new(pathspecs.into_iter())
1042+ Box::new(pathspec.into_iter())
10411043 },
10421044 out,
10431045 err,
@@ -1053,15 +1055,11 @@ pub fn main() -> Result<()> { · 9 unmodified lines
10531055 progress_keep_open,
10541056 None,
10551057 move |progress, out, err| {
1056− use gix::bstr::ByteSlice;
10571058 core::repository::attributes::validate_baseline(
10581059 repository(Mode::StrictWithGitInstallConfig)?,
1059− stdin_or_bail().ok().map(|stdin| {
1060− stdin
1061− .byte_lines()
1062− .filter_map(Result::ok)
1063− .filter_map(|line| gix::path::Spec::from_bytes(line.as_bstr()))
1064− }),
1060+ stdin_or_bail()
1061+ .ok()
1062+ .map(|stdin| stdin.byte_lines().filter_map(Result::ok).map(gix::bstr::BString::from)),
10651063 progress,
10661064 out,
10671065 err,
@@ -1078,7 +1076,7 @@ pub fn main() -> Result<()> { · 10 unmodified lines
10781076 exclude::Subcommands::Query {
10791077 statistics,
10801078 patterns,
1081− pathspecs,
1079+ pathspec,
10821080 show_ignore_patterns,
10831081 } => prepare_and_run(
10841082 "exclude-query",
@@ -1088,18 +1086,17 @@ pub fn main() -> Result<()> { · 3 unmodified lines
10881086 progress_keep_open,
10891087 None,
10901088 move |_progress, out, err| {
1091− use gix::bstr::ByteSlice;
10921089 core::repository::exclude::query(
10931090 repository(Mode::Strict)?,
1094− if pathspecs.is_empty() {
1091+ if pathspec.is_empty() {
10951092 Box::new(
10961093 stdin_or_bail()?
10971094 .byte_lines()
10981095 .filter_map(Result::ok)
1099− .filter_map(|line| gix::path::Spec::from_bytes(line.as_bstr())),
1100− ) as Box<dyn Iterator<Item = gix::path::Spec>>
1096+ .filter_map(|line| gix::pathspec::parse(&line, Default::default()).ok()),
1097+ ) as Box<dyn Iterator<Item = gix::pathspec::Pattern>>
11011098 } else {
1102− Box::new(pathspecs.into_iter())
1099+ Box::new(pathspec.into_iter())
11031100 },
11041101 out,
11051102 err,
@@ -1115,9 +1112,11 @@ pub fn main() -> Result<()> { · 9 unmodified lines
11151112 },
11161113 Subcommands::Index(cmd) => match cmd {
11171114 index::Subcommands::Entries {
1115+ format: entry_format,
11181116 no_attributes,
11191117 attributes_from_index,
11201118 statistics,
1119+ pathspec,
11211120 } => prepare_and_run(
11221121 "index-entries",
11231122 trace,
@@ -1128,10 +1127,15 @@ pub fn main() -> Result<()> { · 4 unmodified lines
11281127 move |_progress, out, err| {
11291128 core::repository::index::entries(
11301129 repository(Mode::LenientWithGitInstallConfig)?,
1130+ pathspec,
11311131 out,
11321132 err,
11331133 core::repository::index::entries::Options {
11341134 format,
1135+ simple: match entry_format {
1136+ index::entries::Format::Simple => true,
1137+ index::entries::Format::Rich => false,
1138+ },
11351139 attributes: if no_attributes {
11361140 None
11371141 } else {
src/plumbing/options/mod.rs+20−2
@@ -610,7 +610,7 @@ pub mod attributes { · 609 unmodified lines
610610 statistics: bool,
611611 /// The git path specifications to list attributes for, or unset to read from stdin one per line.
612612 #[clap(value_parser = AsPathSpec)]
613− pathspecs: Vec<gix::path::Spec>,
613+ pathspec: Vec<gix::pathspec::Pattern>,
614614 },
615615 }
616616 }
@@ -639,18 +639,33 @@ pub mod exclude { · 22 unmodified lines
639639 patterns: Vec<OsString>,
640640 /// The git path specifications to check for exclusion, or unset to read from stdin one per line.
641641 #[clap(value_parser = AsPathSpec)]
642− pathspecs: Vec<gix::path::Spec>,
642+ pathspec: Vec<gix::pathspec::Pattern>,
643643 },
644644 }
645645 }
646646
647647 pub mod index {
648+ use crate::shared::AsPathSpec;
648649 use std::path::PathBuf;
649650
651+ pub mod entries {
652+ #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
653+ pub enum Format {
654+ ///
655+ #[default]
656+ Simple,
657+ /// Use the `.tar` file format, uncompressed.
658+ Rich,
659+ }
660+ }
661+
650662 #[derive(Debug, clap::Subcommand)]
651663 pub enum Subcommands {
652664 /// Print all entries to standard output
653665 Entries {
666+ /// How to output index entries.
667+ #[clap(long, short = 'f', default_value = "simple", value_enum)]
668+ format: entries::Format,
654669 /// Do not visualize excluded entries or attributes per path.
655670 #[clap(long)]
656671 no_attributes: bool,
@@ -662,6 +677,9 @@ pub mod index { · 5 unmodified lines
662677 /// Print various statistics to stderr
663678 #[clap(long, short = 's')]
664679 statistics: bool,
680+ /// The git path specifications to match entries to print.
681+ #[clap(value_parser = AsPathSpec)]
682+ pathspec: Vec<gix::pathspec::Pattern>,
665683 },
666684 /// Create an index from a tree-ish.
667685 #[clap(visible_alias = "read-tree")]
src/porcelain/options.rs+1−1
@@ -125,7 +125,7 @@ pub mod tools { · 124 unmodified lines
125125 TracePath {
126126 /// The path to trace through history.
127127 #[clap(value_parser = AsPathSpec)]
128− path: gix::path::Spec,
128+ path: gix::pathspec::Pattern,
129129 },
130130 }
131131 }
src/shared.rs+10−2
@@ -349,11 +349,19 @@ mod clap { · 348 unmodified lines
349349 pub struct AsPathSpec;
350350
351351 impl TypedValueParser for AsPathSpec {
352− type Value = gix::path::Spec;
352+ type Value = gix::pathspec::Pattern;
353353
354354 fn parse_ref(&self, cmd: &Command, arg: Option<&Arg>, value: &OsStr) -> Result<Self::Value, Error> {
355355 OsStringValueParser::new()
356− .try_map(|arg| gix::path::Spec::try_from(arg.as_os_str()))
356+ .try_map(|arg| {
357+ let arg: &std::path::Path = arg.as_os_str().as_ref();
358+ gix::pathspec::parse(
359+ gix::path::into_bstr(arg).as_ref(),
360+ // TODO(pathspec): it *should* be possible to obtain these defaults from the environment and then act correctly.
361+ // gix should
362+ Default::default(),
363+ )
364+ })
357365 .parse_ref(cmd, arg, value)
358366 }
359367 }