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 @@
1**/generated-archives/*.tar.xz filter=lfs diff=lfs merge=lfs -text1**/generated-archives/*.tar.xz filter=lfs diff=lfs merge=lfs -text
2 2
3# assure line feeds don't interfere with our working copy hash 3# assure line feeds don't interfere with our working copy hash
4**/tests/fixtures/*.sh text crlf=input eol=lf4**/tests/fixtures/**/*.sh text crlf=input eol=lf
5/justfile text crlf=input eol=lf5/justfile text crlf=input eol=lf
Cargo.lock+2−0
@@ -1186,6 +1186,7 @@ dependencies = [ · 1185 unmodified lines
1186 "gix-odb",1186 "gix-odb",
1187 "gix-pack",1187 "gix-pack",
1188 "gix-path 0.8.4",1188 "gix-path 0.8.4",
1189 "gix-pathspec",
1189 "gix-prompt",1190 "gix-prompt",
1190 "gix-protocol",1191 "gix-protocol",
1191 "gix-ref 0.34.0",1192 "gix-ref 0.34.0",
@@ -1969,6 +1970,7 @@ dependencies = [ · 777 unmodified lines
1969 "bstr",1970 "bstr",
1970 "gix-attributes 0.16.0",1971 "gix-attributes 0.16.0",
1971 "gix-glob 0.10.2",1972 "gix-glob 0.10.2",
1973 "gix-path 0.8.4",
1972 "gix-testtools",1974 "gix-testtools",
1973 "once_cell",1975 "once_cell",
1974 "thiserror",1976 "thiserror",
crate-status.md+3−2
@@ -361,8 +361,9 @@ Check out the [performance discussion][gix-traverse-performance] as well. · 360 unmodified lines
361 * [ ] check for match361 * [ ] check for match
362 362
363### gix-pathspec363### gix-pathspec
364* [x] parse364* [x] parse single
365* [ ] matching of paths365* [ ] 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
366 367
367### gix-refspec368### gix-refspec
368* [x] parse369* [x] parse
gitoxide-core/src/query/engine/command.rs+11−5
@@ -1,6 +1,6 @@
1use std::collections::HashMap;1use std::collections::HashMap;
2 2
3use anyhow::Context;3use anyhow::{bail, Context};
4use gix::{bstr::ByteSlice, prelude::ObjectIdExt, Progress};4use gix::{bstr::ByteSlice, prelude::ObjectIdExt, Progress};
5use rusqlite::{params, OptionalExtension};5use rusqlite::{params, OptionalExtension};
6 6
@@ -18,10 +18,16 @@ impl query::Engine { · 11 unmodified lines
18 ) -> anyhow::Result<()> {18 ) -> anyhow::Result<()> {
19 match cmd {19 match cmd {
20 Command::TracePath { mut spec } => {20 Command::TracePath { mut spec } => {
21 if let Some(prefix) = self.repo.prefix() {21 let is_excluded = spec.is_excluded();
22 spec.apply_prefix(&prefix?);22 let relpath = spec
23 };23 .normalize(
24 let relpath = spec.items().next().expect("spec has at least one item");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 }
25 let file_id: usize = self31 let file_id: usize = self
26 .con32 .con
27 .query_row(33 .query_row(
gitoxide-core/src/query/engine/mod.rs+1−1
@@ -1,7 +1,7 @@
1pub enum Command {1pub enum Command {
2 TracePath {2 TracePath {
3 /// The repo-relative path to the file to trace3 /// The repo-relative path to the file to trace
4 spec: gix::path::Spec,4 spec: gix::pathspec::Pattern,
5 },5 },
6}6}
7 7
gitoxide-core/src/repository/attributes/query.rs+23−20
@@ -18,7 +18,7 @@ pub(crate) mod function { · 17 unmodified lines
18 18
19 pub fn query(19 pub fn query(
20 repo: gix::Repository,20 repo: gix::Repository,
21 pathspecs: impl Iterator<Item = gix::path::Spec>,21 pathspecs: impl Iterator<Item = gix::pathspec::Pattern>,
22 mut out: impl io::Write,22 mut out: impl io::Write,
23 mut err: impl io::Write,23 mut err: impl io::Write,
24 Options { format, statistics }: Options,24 Options { format, statistics }: Options,
@@ -28,28 +28,31 @@ pub(crate) mod function { · 3 unmodified lines
28 }28 }
29 29
30 let mut cache = attributes_cache(&repo)?;30 let mut cache = attributes_cache(&repo)?;
31 let prefix = repo.prefix().expect("worktree - we have an index by now")?;
32 let mut matches = cache.attribute_matches();31 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 )?;
33 38
34 for mut spec in pathspecs {39 for spec in search.into_patterns() {
35 for path in spec.apply_prefix(&prefix).items() {40 let is_dir = gix::path::from_bstr(spec.path()).metadata().ok().map(|m| m.is_dir());
36 let is_dir = gix::path::from_bstr(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))?;
37 let entry = cache.at_entry(path, is_dir, |oid, buf| repo.objects.find_blob(oid, buf))?;
38 42
39 if !entry.matching_attributes(&mut matches) {43 if !entry.matching_attributes(&mut matches) {
40 continue;44 continue;
41 }45 }
42 for m in matches.iter() {46 for m in matches.iter() {
43 writeln!(47 writeln!(
44 out,48 out,
45 "{}:{}:{}\t{}\t{}",49 "{}:{}:{}\t{}\t{}",
46 m.location.source.map(Path::to_string_lossy).unwrap_or_default(),50 m.location.source.map(Path::to_string_lossy).unwrap_or_default(),
47 m.location.sequence_number,51 m.location.sequence_number,
48 m.pattern,52 m.pattern,
49 path,53 spec.path(),
50 m.assignment54 m.assignment
51 )?;55 )?;
52 }
53 }56 }
54 }57 }
55 58
gitoxide-core/src/repository/attributes/validate_baseline.rs+17−22
@@ -18,6 +18,7 @@ pub(crate) mod function { · 17 unmodified lines
18 };18 };
19 19
20 use anyhow::{anyhow, bail};20 use anyhow::{anyhow, bail};
21 use gix::bstr::BString;
21 use gix::{attrs::Assignment, odb::FindExt, Progress};22 use gix::{attrs::Assignment, odb::FindExt, Progress};
22 23
23 use crate::{24 use crate::{
@@ -27,7 +28,7 @@ pub(crate) mod function { · 3 unmodified lines
27 28
28 pub fn validate_baseline(29 pub fn validate_baseline(
29 repo: gix::Repository,30 repo: gix::Repository,
30 pathspecs: Option<impl Iterator<Item = gix::path::Spec> + Send + 'static>,31 paths: Option<impl Iterator<Item = BString> + Send + 'static>,
31 mut progress: impl Progress + 'static,32 mut progress: impl Progress + 'static,
32 mut out: impl io::Write,33 mut out: impl io::Write,
33 mut err: impl io::Write,34 mut err: impl io::Write,
@@ -51,7 +52,7 @@ pub(crate) mod function { · 17 unmodified lines
51 ignore = false;52 ignore = false;
52 }53 }
53 let mut num_entries = None;54 let mut num_entries = None;
54 let pathspecs = pathspecs.map_or_else(55 let paths = paths.map_or_else(
55 {56 {
56 let repo = repo.clone();57 let repo = repo.clone();
57 let num_entries = &mut num_entries;58 let num_entries = &mut num_entries;
@@ -59,18 +60,16 @@ pub(crate) mod function { · 1 unmodified line
59 let index = repo.index_or_load_from_head()?.into_owned();60 let index = repo.index_or_load_from_head()?.into_owned();
60 let (entries, path_backing) = index.into_parts().0.into_entries();61 let (entries, path_backing) = index.into_parts().0.into_entries();
61 *num_entries = Some(entries.len());62 *num_entries = Some(entries.len());
62 let iter = Box::new(entries.into_iter().map(move |e| {63 let iter = Box::new(entries.into_iter().map(move |e| e.path_in(&path_backing).to_owned()));
63 gix::path::Spec::from_bytes(e.path_in(&path_backing)).expect("each entry path is a valid spec")64 Ok(iter as Box<dyn Iterator<Item = BString> + Send + 'static>)
64 }));
65 Ok(iter as Box<dyn Iterator<Item = gix::path::Spec> + Send + 'static>)
66 }65 }
67 },66 },
68 |i| anyhow::Result::Ok(Box::new(i)),67 |paths| anyhow::Result::Ok(Box::new(paths)),
69 )?;68 )?;
70 69
71 let (tx_base, rx_base) = std::sync::mpsc::channel::<(String, Baseline)>();70 let (tx_base, rx_base) = std::sync::mpsc::channel::<(String, Baseline)>();
72 let feed_attrs = {71 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);
74 std::thread::spawn({73 std::thread::spawn({
75 let path = repo.path().to_owned();74 let path = repo.path().to_owned();
76 let tx_base = tx_base.clone();75 let tx_base = tx_base.clone();
@@ -89,12 +88,10 @@ pub(crate) mod function { · 12 unmodified lines
89 move || -> anyhow::Result<()> {88 move || -> anyhow::Result<()> {
90 progress.init(num_entries, gix::progress::count("paths"));89 progress.init(num_entries, gix::progress::count("paths"));
91 let start = std::time::Instant::now();90 let start = std::time::Instant::now();
92 for spec in rx {91 for path in rx {
93 progress.inc();92 progress.inc();
94 for path in spec.items() {93 stdin.write_all(&path)?;
95 stdin.write_all(path.as_ref())?;94 stdin.write_all(b"\n")?;
96 stdin.write_all(b"\n")?;
97 }
98 }95 }
99 progress.show_throughput(start);96 progress.show_throughput(start);
100 Ok(())97 Ok(())
@@ -123,7 +120,7 @@ pub(crate) mod function { · 22 unmodified lines
123 })120 })
124 .transpose()?;121 .transpose()?;
125 let feed_excludes = ignore.then(|| {122 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);
127 std::thread::spawn({124 std::thread::spawn({
128 let path = work_dir.expect("present if we are here");125 let path = work_dir.expect("present if we are here");
129 let tx_base = tx_base.clone();126 let tx_base = tx_base.clone();
@@ -142,12 +139,10 @@ pub(crate) mod function { · 12 unmodified lines
142 move || -> anyhow::Result<()> {139 move || -> anyhow::Result<()> {
143 progress.init(num_entries, gix::progress::count("paths"));140 progress.init(num_entries, gix::progress::count("paths"));
144 let start = std::time::Instant::now();141 let start = std::time::Instant::now();
145 for spec in rx {142 for path in rx {
146 progress.inc();143 progress.inc();
147 for path in spec.items() {144 stdin.write_all(path.as_ref())?;
148 stdin.write_all(path.as_ref())?;145 stdin.write_all(b"\n")?;
149 stdin.write_all(b"\n")?;
150 }
151 }146 }
152 progress.show_throughput(start);147 progress.show_throughput(start);
153 Ok(())148 Ok(())
@@ -175,12 +170,12 @@ pub(crate) mod function { · 21 unmodified lines
175 drop(tx_base);170 drop(tx_base);
176 171
177 std::thread::spawn(move || {172 std::thread::spawn(move || {
178 for spec in pathspecs {173 for path in paths {
179 if feed_attrs.send(spec.clone()).is_err() {174 if feed_attrs.send(path.clone()).is_err() {
180 break;175 break;
181 }176 }
182 if let Some(ch) = feed_excludes.as_ref() {177 if let Some(ch) = feed_excludes.as_ref() {
183 if ch.send(spec).is_err() {178 if ch.send(path).is_err() {
184 break;179 break;
185 }180 }
186 }181 }
gitoxide-core/src/repository/exclude.rs+24−22
@@ -20,7 +20,7 @@ pub mod query { · 19 unmodified lines
20 20
21pub fn query(21pub fn query(
22 repo: gix::Repository,22 repo: gix::Repository,
23 pathspecs: impl Iterator<Item = gix::path::Spec>,23 pathspecs: impl Iterator<Item = gix::pathspec::Pattern>,
24 mut out: impl io::Write,24 mut out: impl io::Write,
25 mut err: impl io::Write,25 mut err: impl io::Write,
26 query::Options {26 query::Options {
@@ -41,28 +41,30 @@ pub fn query( · 14 unmodified lines
41 Default::default(),41 Default::default(),
42 )?;42 )?;
43 43
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 )?;
45 50
46 for mut spec in pathspecs {51 for spec in search.into_patterns() {
47 for path in spec.apply_prefix(&prefix).items() {52 let path = spec.path();
48 // TODO: what about paths that end in /? Pathspec might handle it, it's definitely something git considers53 let is_dir = gix::path::from_bstr(path).metadata().ok().map(|m| m.is_dir());
49 // even if the directory doesn't exist. Seems to work as long as these are kept in the spec.54 let entry = cache.at_entry(path, is_dir, |oid, buf| repo.objects.find_blob(oid, buf))?;
50 let is_dir = gix::path::from_bstr(path).metadata().ok().map(|m| m.is_dir());55 let match_ = entry
51 let entry = cache.at_entry(path, is_dir, |oid, buf| repo.objects.find_blob(oid, buf))?;56 .matching_exclude_pattern()
52 let match_ = entry57 .and_then(|m| (show_ignore_patterns || !m.pattern.is_negative()).then_some(m));
53 .matching_exclude_pattern()58 match match_ {
54 .and_then(|m| (show_ignore_patterns || !m.pattern.is_negative()).then_some(m));59 Some(m) => writeln!(
55 match match_ {60 out,
56 Some(m) => writeln!(61 "{}:{}:{}\t{}",
57 out,62 m.source.map(std::path::Path::to_string_lossy).unwrap_or_default(),
58 "{}:{}:{}\t{}",63 m.sequence_number,
59 m.source.map(std::path::Path::to_string_lossy).unwrap_or_default(),64 m.pattern,
60 m.sequence_number,65 path
61 m.pattern,66 )?,
62 path67 None => writeln!(out, "::\t{path}")?,
63 )?,
64 None => writeln!(out, "::\t{path}")?,
65 }
66 }68 }
67 }69 }
68 70
gitoxide-core/src/repository/index/entries.rs+148−57
@@ -4,9 +4,10 @@ pub struct Options { · 3 unmodified lines
4 /// If true, also show attributes4 /// If true, also show attributes
5 pub attributes: Option<Attributes>,5 pub attributes: Option<Attributes>,
6 pub statistics: bool,6 pub statistics: bool,
7 pub simple: bool,
7}8}
8 9
9#[derive(Debug)]10#[derive(Debug, Copy, Clone)]
10pub enum Attributes {11pub enum Attributes {
11 /// Look at worktree attributes and index as fallback.12 /// Look at worktree attributes and index as fallback.
12 WorktreeAndIndex,13 WorktreeAndIndex,
@@ -15,6 +16,7 @@ pub enum Attributes { · 2 unmodified lines
15}16}
16 17
17pub(crate) mod function {18pub(crate) mod function {
19 use std::collections::BTreeSet;
18 use std::{20 use std::{
19 borrow::Cow,21 borrow::Cow,
20 io::{BufWriter, Write},22 io::{BufWriter, Write},
@@ -26,9 +28,11 @@ pub(crate) mod function { · 5 unmodified lines
26 28
27 pub fn entries(29 pub fn entries(
28 repo: gix::Repository,30 repo: gix::Repository,
31 pathspecs: Vec<gix::pathspec::Pattern>,
29 out: impl std::io::Write,32 out: impl std::io::Write,
30 mut err: impl std::io::Write,33 mut err: impl std::io::Write,
31 Options {34 Options {
35 simple,
32 format,36 format,
33 attributes,37 attributes,
34 statistics,38 statistics,
@@ -37,6 +41,12 @@ pub(crate) mod function { · 2 unmodified lines
37 use crate::OutputFormat::*;41 use crate::OutputFormat::*;
38 let index = repo.index_or_load_from_head()?;42 let index = repo.index_or_load_from_head()?;
39 let mut cache = attributes43 let mut cache = attributes
44 .or_else(|| {
45 pathspecs
46 .iter()
47 .any(|spec| !spec.attributes.is_empty())
48 .then_some(Attributes::Index)
49 })
40 .map(|attrs| {50 .map(|attrs| {
41 repo.attributes(51 repo.attributes(
42 &index,52 &index,
@@ -70,52 +80,111 @@ pub(crate) mod function { · 27 unmodified lines
70 ..Default::default()80 ..Default::default()
71 };81 };
72 82
73 let mut out = BufWriter::new(out);83 let mut out = BufWriter::with_capacity(64 * 1024, out);
74 #[cfg(feature = "serde")]84 #[cfg(feature = "serde")]
75 if let Json = format {85 if let Json = format {
76 out.write_all(b"[\n")?;86 out.write_all(b"[\n")?;
77 }87 }
78 let mut entries = index.entries().iter().peekable();88 let mut search = gix::pathspec::Search::from_specs(
79 while let Some(entry) = entries.next() {89 pathspecs,
80 let attrs = cache90 repo.prefix()?.as_deref(),
81 .as_mut()91 gix::path::realpath(repo.work_dir().unwrap_or_else(|| repo.git_dir()))?.as_ref(), // TODO(pathspec): this setup needs `gix`.
82 .map(|(attrs, cache)| {92 )?;
83 cache93 let mut all_attrs = statistics.then(BTreeSet::new);
84 .at_entry(entry.path(&index), None, |id, buf| repo.objects.find_blob(id, buf))94 if let Some(entries) = index.prefixed_entries(search.common_prefix()) {
85 .map(|entry| {95 stats.entries_after_prune = entries.len();
86 let is_excluded = entry.is_excluded();96 let mut entries = entries.iter().peekable();
87 stats.excluded += usize::from(is_excluded);97 while let Some(entry) = entries.next() {
88 let attributes: Vec<_> = {98 let mut last_match = None;
89 entry.matching_attributes(attrs);99 let attrs = cache
90 attrs.iter().map(|m| m.assignment.to_owned()).collect()100 .as_mut()
91 };101 .and_then(|(attrs, cache)| {
92 stats.with_attributes += usize::from(!attributes.is_empty());102 // If the user wants to see assigned attributes, we always have to match.
93 Attrs {103 attributes.is_some().then(|| {
94 is_excluded,104 cache
95 attributes,105 .at_entry(entry.path(&index), None, |id, buf| repo.objects.find_blob(id, buf))
96 }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 })
97 })125 })
98 })126 })
99 .transpose()?;127 .transpose()?;
100 match format {128
101 Human => to_human(&mut out, &index, entry, attrs)?,129 // Note that we intentionally ignore `_case` so that we act like git does, attribute matching case is determined
102 #[cfg(feature = "serde")]130 // by the repository, not the pathspec.
103 Json => to_json(&mut out, &index, entry, attrs, entries.peek().is_none())?,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 }
104 }167 }
105 }
106 168
107 #[cfg(feature = "serde")]169 #[cfg(feature = "serde")]
108 if format == Json {170 if format == Json {
109 out.write_all(b"]\n")?;171 out.write_all(b"]\n")?;
110 out.flush()?;172 out.flush()?;
111 if statistics {173 if statistics {
112 serde_json::to_writer_pretty(&mut err, &stats)?;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 }
113 }187 }
114 }
115 if format == Human && statistics {
116 out.flush()?;
117 stats.cache = cache.map(|c| *c.1.statistics());
118 writeln!(err, "{stats:#?}")?;
119 }188 }
120 Ok(())189 Ok(())
121 }190 }
@@ -131,8 +200,10 @@ pub(crate) mod function { · 9 unmodified lines
131 struct Statistics {200 struct Statistics {
132 #[allow(dead_code)] // Not really dead, but Debug doesn't count for it even though it's crucial.201 #[allow(dead_code)] // Not really dead, but Debug doesn't count for it even though it's crucial.
133 pub entries: usize,202 pub entries: usize,
203 pub entries_after_prune: usize,
134 pub excluded: usize,204 pub excluded: usize,
135 pub with_attributes: usize,205 pub with_attributes: usize,
206 pub max_attributes_per_path: usize,
136 pub cache: Option<gix::worktree::cache::Statistics>,207 pub cache: Option<gix::worktree::cache::Statistics>,
137 }208 }
138 209
@@ -175,6 +246,22 @@ pub(crate) mod function { · 36 unmodified lines
175 Ok(())246 Ok(())
176 }247 }
177 248
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
178 fn to_human(265 fn to_human(
179 out: &mut impl std::io::Write,266 out: &mut impl std::io::Write,
180 file: &gix::index::File,267 file: &gix::index::File,
@@ -198,24 +285,28 @@ pub(crate) mod function { · 17 unmodified lines
198 entry.mode,285 entry.mode,
199 entry.id,286 entry.id,
200 entry.path(file),287 entry.path(file),
201 attrs.map_or(Cow::Borrowed(""), |a| {288 print_attrs(attrs)
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 })
219 )289 )
220 }290 }
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 }
221}312}
gix-attributes/src/search/attributes.rs+9−1
@@ -181,7 +181,9 @@ impl Pattern for Attributes { · 180 unmodified lines
181 })181 })
182 .collect()182 .collect()
183 }183 }
184}
184 185
186impl Attributes {
185 fn may_use_glob_pattern(pattern: &gix_glob::Pattern) -> bool {187 fn may_use_glob_pattern(pattern: &gix_glob::Pattern) -> bool {
186 pattern.mode != macro_mode()188 pattern.mode != macro_mode()
187 }189 }
@@ -229,7 +231,13 @@ fn pattern_matching_relative_path( · 41 unmodified lines
229 Value::Assignments(attrs) => attrs,231 Value::Assignments(attrs) => attrs,
230 };232 };
231 if out.has_unspecified_attributes(attrs.iter().map(|attr| attr.id))233 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 )
233 {241 {
234 let all_filled = out.fill_attributes(attrs.iter(), pattern, list.source.as_ref(), *sequence_number);242 let all_filled = out.fill_attributes(attrs.iter(), pattern, list.source.as_ref(), *sequence_number);
235 if all_filled {243 if all_filled {
gix-attributes/src/search/outcome.rs+43−0
@@ -73,6 +73,26 @@ impl Outcome { · 72 unmodified lines
73 self.selected.iter().filter(|(_name, id)| id.is_some()).count()73 self.selected.iter().filter(|(_name, id)| id.is_some()).count()
74 });74 });
75 }75 }
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 }
76}96}
77 97
78/// Access98/// Access
@@ -223,6 +243,29 @@ impl Outcome { · 144 unmodified lines
223 }243 }
224}244}
225 245
246impl 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
226/// Mutation269/// Mutation
227impl MetadataCollection {270impl MetadataCollection {
228 /// Assign order ids to each attribute either in macros (along with macros themselves) or attributes of patterns, and store271 /// 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
21where21where
22 T: Hash + Clone,22 T: Hash + Clone,
23{23{
24 pub(crate) fn len(&self) -> usize {
25 self.0.len()
26 }
24 pub(crate) fn insert(&mut self, value: &T) -> RefMapKey {27 pub(crate) fn insert(&mut self, value: &T) -> RefMapKey {
25 let mut s = DefaultHasher::new();28 let mut s = DefaultHasher::new();
26 value.hash(&mut s);29 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
141 true, /* use macros */141 true, /* use macros */
142 )?;142 )?;
143 143
144 let mut out = gix_attributes::search::Outcome::default();144 let mut out = Outcome::default();
145 out.initialize(&collection);145 out.initialize(&collection);
146 let mut alt = Outcome::default();
147 alt.initialize(&collection);
146 148
147 let mut orders = collection149 let mut orders = collection
148 .iter()150 .iter()
@@ -206,10 +208,13 @@ fn all_attributes_are_listed_in_declaration_order() -> crate::Result { · 57 unmodified lines
206 assert_references(&out);208 assert_references(&out);
207 let actual: Vec<_> = out.iter().map(|m| m.assignment).collect();209 let actual: Vec<_> = out.iter().map(|m| m.assignment).collect();
208 assert_eq!(210 assert_eq!(
209 by_name(actual),211 by_name(actual.clone()),
210 by_name(expected),212 by_name(expected),
211 "{rela_path}: the order of everything matches perfectly"213 "{rela_path}: the order of everything matches perfectly"
212 );214 );
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);
213 }218 }
214 assert_eq!(219 assert_eq!(
215 out.iter().count(),220 out.iter().count(),
@@ -234,8 +239,10 @@ fn given_attributes_are_made_available_in_given_order() -> crate::Result { · 18 unmodified lines
234 true, /* use macros */239 true, /* use macros */
235 )?;240 )?;
236 241
237 let mut out = gix_attributes::search::Outcome::default();242 let mut out = Outcome::default();
238 out.initialize_with_selection(&collection, ["my-binary", "recursive", "unspecified"]);243 out.initialize_with_selection(&collection, ["my-binary", "recursive", "unspecified"]);
244 let mut alt = Outcome::default();
245 alt.initialize_with_selection(&collection, ["my-binary", "unspecified"]);
239 246
240 for (rela_path, expected) in (baseline::Expectations { lines: input.lines() }) {247 for (rela_path, expected) in (baseline::Expectations { lines: input.lines() }) {
241 out.reset();248 out.reset();
@@ -246,6 +253,10 @@ fn given_attributes_are_made_available_in_given_order() -> crate::Result { · 4 unmodified lines
246 actual, expected,253 actual, expected,
247 "{rela_path}: the order of everything matches perfectly"254 "{rela_path}: the order of everything matches perfectly"
248 );255 );
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]);
249 }260 }
250 assert_eq!(261 assert_eq!(
251 out.iter().count(),262 out.iter().count(),
@@ -255,6 +266,15 @@ fn given_attributes_are_made_available_in_given_order() -> crate::Result { · 3 unmodified lines
255 Ok(())266 Ok(())
256}267}
257 268
269#[test]
270fn 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
258fn by_name(assignments: Vec<AssignmentRef>) -> BTreeMap<NameRef, StateRef> {278fn by_name(assignments: Vec<AssignmentRef>) -> BTreeMap<NameRef, StateRef> {
259 assignments.into_iter().map(|a| (a.name, a.state)).collect()279 assignments.into_iter().map(|a| (a.name, a.state)).collect()
260}280}
gix-glob/src/parse.rs+11−7
@@ -6,19 +6,23 @@ use crate::{pattern, pattern::Mode}; · 5 unmodified lines
6/// A sloppy parser that performs only the most basic checks, providing additional information6/// A sloppy parser that performs only the most basic checks, providing additional information
7/// using `pattern::Mode` flags.7/// using `pattern::Mode` flags.
8///8///
9/// If `may_alter` is `false`, we won't parse leading `!` or its escaped form.
10///
9/// Returns `(pattern, mode, no_wildcard_len)`11/// Returns `(pattern, mode, no_wildcard_len)`
10pub fn pattern(mut pat: &[u8]) -> Option<(&[u8], pattern::Mode, Option<usize>)> {12pub fn pattern(mut pat: &[u8], may_alter: bool) -> Option<(&[u8], pattern::Mode, Option<usize>)> {
11 let mut mode = Mode::empty();13 let mut mode = Mode::empty();
12 if pat.is_empty() {14 if pat.is_empty() {
13 return None;15 return None;
14 };16 };
15 if pat.first() == Some(&b'!') {17 if may_alter {
16 mode |= Mode::NEGATIVE;18 if pat.first() == Some(&b'!') {
17 pat = &pat[1..];19 mode |= Mode::NEGATIVE;
18 } else if pat.first() == Some(&b'\\') {
19 let second = pat.get(1);
20 if second == Some(&b'!') || second == Some(&b'#') {
21 pat = &pat[1..];20 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 }
22 }26 }
23 }27 }
24 if pat.iter().all(u8::is_ascii_whitespace) {28 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
44impl Pattern {44impl Pattern {
45 /// Parse the given `text` as pattern, or return `None` if `text` was empty.45 /// Parse the given `text` as pattern, or return `None` if `text` was empty.
46 pub fn from_bytes(text: &[u8]) -> Option<Self> {46 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 {
48 text: text.into(),59 text: text.into(),
49 mode,60 mode,
50 first_wildcard_pos,61 first_wildcard_pos,
@@ -65,30 +76,37 @@ impl Pattern { · 14 unmodified lines
65 /// We may take various shortcuts which is when `basename_start_pos` and `is_dir` come into play.76 /// We may take various shortcuts which is when `basename_start_pos` and `is_dir` come into play.
66 /// `basename_start_pos` is the index at which the `path`'s basename starts.77 /// `basename_start_pos` is the index at which the `path`'s basename starts.
67 ///78 ///
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.
69 pub fn matches_repo_relative_path<'a>(81 pub fn matches_repo_relative_path<'a>(
70 &self,82 &self,
71 path: impl Into<&'a BStr>,83 path: impl Into<&'a BStr>,
72 basename_start_pos: Option<usize>,84 basename_start_pos: Option<usize>,
73 is_dir: Option<bool>,85 is_dir: Option<bool>,
74 case: Case,86 case: Case,
87 mode: wildmatch::Mode,
75 ) -> bool {88 ) -> bool {
76 let is_dir = is_dir.unwrap_or(false);89 let is_dir = is_dir.unwrap_or(false);
77 if !is_dir && self.mode.contains(pattern::Mode::MUST_BE_DIR) {90 if !is_dir && self.mode.contains(pattern::Mode::MUST_BE_DIR) {
78 return false;91 return false;
79 }92 }
80 93
81 let flags = wildmatch::Mode::NO_MATCH_SLASH_LITERAL94 let flags = mode
82 | match case {95 | match case {
83 Case::Fold => wildmatch::Mode::IGNORE_CASE,96 Case::Fold => wildmatch::Mode::IGNORE_CASE,
84 Case::Sensitive => wildmatch::Mode::empty(),97 Case::Sensitive => wildmatch::Mode::empty(),
85 };98 };
86 let path = path.into();99 let path = path.into();
87 debug_assert_eq!(100 #[cfg(debug_assertions)]
88 basename_start_pos,101 {
89 path.rfind_byte(b'/').map(|p| p + 1),102 if basename_start_pos.is_some() {
90 "BUG: invalid cached basename_start_pos provided"103 debug_assert_eq!(
91 );104 basename_start_pos,
105 path.rfind_byte(b'/').map(|p| p + 1),
106 "BUG: invalid cached basename_start_pos provided"
107 );
108 }
109 }
92 debug_assert!(!path.starts_with(b"/"), "input path must be relative");110 debug_assert!(!path.starts_with(b"/"), "input path must be relative");
93 111
94 if self.mode.contains(pattern::Mode::NO_SUB_DIR) && !self.mode.contains(pattern::Mode::ABSOLUTE) {112 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
16 16
17 /// Parse all patterns in `bytes` line by line, ignoring lines with errors, and collect them.17 /// Parse all patterns in `bytes` line by line, ignoring lines with errors, and collect them.
18 fn bytes_to_patterns(bytes: &[u8], source: &Path) -> Vec<pattern::Mapping<Self::Value>>;18 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;
22}19}
23 20
24/// Add the given file at `source` if it exists, otherwise do nothing.21/// 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
66 gix_glob::parse(b"!hello"),66 gix_glob::parse(b"!hello"),
67 pat("hello", Mode::NEGATIVE | Mode::NO_SUB_DIR, None)67 pat("hello", Mode::NEGATIVE | Mode::NO_SUB_DIR, None)
68 );68 );
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 );
69}74}
70 75
71#[test]76#[test]
72fn leading_exclamation_marks_can_be_escaped_with_backslash() {77fn leading_exclamation_marks_can_be_escaped_with_backslash() {
73 assert_eq!(gix_glob::parse(br"\!hello"), pat("!hello", Mode::NO_SUB_DIR, None));78 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 );
74}84}
75 85
76#[test]86#[test]
gix-glob/tests/pattern/matching.rs+28−4
@@ -68,7 +68,13 @@ fn compare_baseline_with_ours() { · 67 unmodified lines
68 );68 );
69 match std::panic::catch_unwind(|| {69 match std::panic::catch_unwind(|| {
70 let pattern = pat(pattern);70 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 )
72 }) {78 }) {
73 Ok(actual_match) => {79 Ok(actual_match) => {
74 if actual_match == is_match {80 if actual_match == is_match {
@@ -105,11 +111,23 @@ fn non_dirs_for_must_be_dir_patterns_are_ignored() { · 30 unmodified lines
105 );111 );
106 let path = "hello";112 let path = "hello";
107 assert!(113 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 ),
109 "non-dirs never match a dir pattern"121 "non-dirs never match a dir pattern"
110 );122 );
111 assert!(123 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 ),
113 "dirs can match a dir pattern with the normal rules"131 "dirs can match a dir pattern with the normal rules"
114 );132 );
115}133}
@@ -317,7 +335,13 @@ fn match_file<'a>(pattern: &gix_glob::Pattern, path: impl Into<&'a BStr>, case: · 201 unmodified lines
317 335
318fn match_path<'a>(pattern: &gix_glob::Pattern, path: impl Into<&'a BStr>, is_dir: Option<bool>, case: Case) -> bool {336fn match_path<'a>(pattern: &gix_glob::Pattern, path: impl Into<&'a BStr>, is_dir: Option<bool>, case: Case) -> bool {
319 let path = path.into();337 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 )
321}345}
322 346
323fn basename_start_pos(value: &BStr) -> Option<usize> {347fn basename_start_pos(value: &BStr) -> Option<usize> {
gix-glob/tests/search/pattern.rs+0−4
@@ -18,10 +18,6 @@ mod list { · 17 unmodified lines
18 fn bytes_to_patterns(_bytes: &[u8], _source: &Path) -> Vec<Mapping<Self::Value>> {18 fn bytes_to_patterns(_bytes: &[u8], _source: &Path) -> Vec<Mapping<Self::Value>> {
19 vec![]19 vec![]
20 }20 }
21
22 fn may_use_glob_pattern(_pattern: &gix_glob::Pattern) -> bool {
23 unreachable!("won't be called")
24 }
25 }21 }
26 22
27 #[test]23 #[test]
gix-glob/tests/wildmatch/mod.rs+7−1
@@ -367,7 +367,13 @@ impl Display for MatchResult { · 366 unmodified lines
367}367}
368 368
369fn match_file_path(pattern: &gix_glob::Pattern, path: &str, case: Case) -> bool {369fn 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 )
371}377}
372fn basename_of(path: &str) -> Option<usize> {378fn basename_of(path: &str) -> Option<usize> {
373 path.rfind('/').map(|pos| pos + 1)379 path.rfind('/').map(|pos| pos + 1)
gix-ignore/src/search.rs+32−33
@@ -35,10 +35,6 @@ impl Pattern for Ignore { · 34 unmodified lines
35 })35 })
36 .collect()36 .collect()
37 }37 }
38
39 fn may_use_glob_pattern(_pattern: &gix_glob::Pattern) -> bool {
40 true
41 }
42}38}
43 39
44/// Instantiation of a search for ignore patterns.40/// Instantiation of a search for ignore patterns.
@@ -115,25 +111,27 @@ pub fn pattern_matching_relative_path<'a>( · 70 unmodified lines
115) -> Option<Match<'a>> {111) -> Option<Match<'a>> {
116 let (relative_path, basename_start_pos) =112 let (relative_path, basename_start_pos) =
117 list.strip_base_handle_recompute_basename_pos(relative_path, basename_pos, case)?;113 list.strip_base_handle_recompute_basename_pos(relative_path, basename_pos, case)?;
118 list.patterns114 list.patterns.iter().rev().find_map(
119 .iter()115 |pattern::Mapping {
120 .rev()116 pattern,
121 .filter(|pm| Ignore::may_use_glob_pattern(&pm.pattern))117 value: (),
122 .find_map(118 sequence_number,
123 |pattern::Mapping {119 }| {
124 pattern,120 pattern
125 value: (),121 .matches_repo_relative_path(
126 sequence_number,122 relative_path,
127 }| {123 basename_start_pos,
128 pattern124 is_dir,
129 .matches_repo_relative_path(relative_path, basename_start_pos, is_dir, case)125 case,
130 .then_some(Match {126 gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
131 pattern,127 )
132 source: list.source.as_deref(),128 .then_some(Match {
133 sequence_number: *sequence_number,129 pattern,
134 })130 source: list.source.as_deref(),
135 },131 sequence_number: *sequence_number,
136 )132 })
133 },
134 )
137}135}
138 136
139/// Like [`pattern_matching_relative_path()`], but returns an index to the pattern137/// 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
147) -> Option<usize> {145) -> Option<usize> {
148 let (relative_path, basename_start_pos) =146 let (relative_path, basename_start_pos) =
149 list.strip_base_handle_recompute_basename_pos(relative_path, basename_pos, case)?;147 list.strip_base_handle_recompute_basename_pos(relative_path, basename_pos, case)?;
150 list.patterns148 list.patterns.iter().enumerate().rev().find_map(|(idx, pm)| {
151 .iter()149 pm.pattern
152 .enumerate()150 .matches_repo_relative_path(
153 .rev()151 relative_path,
154 .filter(|(_, pm)| Ignore::may_use_glob_pattern(&pm.pattern))152 basename_start_pos,
155 .find_map(|(idx, pm)| {153 is_dir,
156 pm.pattern154 case,
157 .matches_repo_relative_path(relative_path, basename_start_pos, is_dir, case)155 gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
158 .then_some(idx)156 )
159 })157 .then_some(idx)
158 })
160}159}
161 160
162/// Matching of ignore patterns.161/// Matching of ignore patterns.
gix-index/src/access/mod.rs+28−0
@@ -158,6 +158,34 @@ impl State { · 157 unmodified lines
158 Some(&self.entries[idx])158 Some(&self.entries[idx])
159 }159 }
160 160
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
161 /// Return the entry at `idx` or _panic_ if the index is out of bounds.189 /// Return the entry at `idx` or _panic_ if the index is out of bounds.
162 ///190 ///
163 /// The `idx` is typically returned by [`entry_by_path_and_stage()`][State::entry_by_path_and_stage()].191 /// 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
28 file.entry_by_path("file".into()).expect("found").stage(),28 file.entry_by_path("file".into()).expect("found").stage(),
29 2,29 2,
30 "we always find our stage while in a merge"30 "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 );
32}42}
33 43
34#[test]44#[test]
@@ -65,4 +75,31 @@ fn sort_entries() { · 30 unmodified lines
65 new_entry_path,75 new_entry_path,
66 "we can find the correct entry now"76 "we can find the correct entry now"
67 );77 );
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
95fn 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 );
68}105}
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
239/// Resolve relative components virtually without accessing the file system, e.g. turn `a/./b/c/.././..` into `a`,239/// Resolve relative components virtually without accessing the file system, e.g. turn `a/./b/c/.././..` into `a`,
240/// without keeping intermediate `..` and `/a/../b/..` becomes `/`.240/// without keeping intermediate `..` and `/a/../b/..` becomes `/`.
241/// If the input path was relative and ends up being the `current_dir`, `.` is returned instead of the full path to `current_dir`.241/// 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.
242///243///
243/// This is particularly useful when manipulating paths that are based on user input, and not resolving intermediate244/// This is particularly useful when manipulating paths that are based on user input, and not resolving intermediate
244/// symlinks keeps the path similar to what the user provided. If that's not desirable, use `[realpath()][crate::realpath()`245/// 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
50#![forbid(unsafe_code)]50#![forbid(unsafe_code)]
51 51
52/// A dummy type to represent path specs and help finding all spots that take path specs once it is implemented.52/// 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)]
56pub struct Spec(bstr::BString);
57
58mod convert;53mod convert;
59pub use convert::*;54pub use convert::*;
60 55
61mod util;56mod util;
62pub use util::is_absolute;57pub use util::is_absolute;
63 58
64mod spec;
65
66///59///
67pub mod realpath;60pub mod realpath;
68pub use realpath::function::{realpath, realpath_opts};61pub use realpath::function::{realpath, realpath_opts};
gix-path/src/spec.rs+0−53
@@ -1,53 +0,0 @@
1use std::ffi::OsStr;
2
3use bstr::{BStr, ByteSlice, ByteVec};
4
5use crate::Spec;
6
7impl 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
18fn 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
26impl 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
117 ("./a/./b/..", "./a/."),117 ("./a/./b/..", "./a/."),
118 ("/a/./b/c/.././../.", "/a"),118 ("/a/./b/c/.././../.", "/a"),
119 ("/a/./b", "/a/./b"),119 ("/a/./b", "/a/./b"),
120 ("././/a/./b", "././/a/./b"),
120 ("/a/././c/.././../.", "/"),121 ("/a/././c/.././../.", "/"),
121 ("/a/b/../c/../..", "/"),122 ("/a/b/../c/../..", "/"),
122 ("C:/hello/../a", "C:/a"),123 ("C:/hello/../a", "C:/a"),
gix-pathspec/Cargo.toml+1−0
@@ -13,6 +13,7 @@ doctest = false · 12 unmodified lines
13 13
14[dependencies]14[dependencies]
15gix-glob = { version = "^0.10.2", path = "../gix-glob" }15gix-glob = { version = "^0.10.2", path = "../gix-glob" }
16gix-path = { version = "^0.8.4", path = "../gix-path" }
16gix-attributes = { version = "^0.16.0", path = "../gix-attributes" }17gix-attributes = { version = "^0.16.0", path = "../gix-attributes" }
17 18
18bstr = { version = "1.3.0", default-features = false, features = ["std"]}19bstr = { 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
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
3 3
4fuzz_target!(|data: &[u8]| {4fuzz_target!(|data: &[u8]| {
5 let _a = gix_pathspec::parse(data);5 let _a = gix_pathspec::parse(data, Default::default());
6});6});
gix-pathspec/src/lib.rs+66−9
@@ -5,17 +5,61 @@ · 4 unmodified lines
5 5
6use bitflags::bitflags;6use bitflags::bitflags;
7use bstr::BString;7use bstr::BString;
8use std::path::PathBuf;
9
10///
11pub 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
25mod pattern;
26
27///
28pub mod search;
8 29
9///30///
10pub mod parse;31pub mod parse;
11 32
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)]
37pub 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
12/// The output of a pathspec [parsing][parse()] operation. It can be used to match against a one or more paths.55/// 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)]
14pub struct Pattern {57pub 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.
16 ///60 ///
17 /// `:(top,literal,icase,attr,exclude)some/path` would yield `some/path`.61 /// For example, `:(top,literal,icase,attr,exclude)some/path` would yield `some/path`.
18 pub path: BString,62 path: BString,
19 /// All magic signatures that were included in the pathspec.63 /// All magic signatures that were included in the pathspec.
20 pub signature: MagicSignature,64 pub signature: MagicSignature,
21 /// The search mode of the pathspec.65 /// The search mode of the pathspec.
@@ -24,11 +68,18 @@ pub struct Pattern { · 2 unmodified lines
24 ///68 ///
25 /// `:(attr:a=one b=):path` would yield attribute `a` and `b`.69 /// `:(attr:a=one b=):path` would yield attribute `a` and `b`.
26 pub attributes: Vec<gix_attributes::Assignment>,70 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,
27}78}
28 79
29bitflags! {80bitflags! {
30 /// Flags to represent 'magic signatures' which are parsed behind colons, like `:top:`.81 /// 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)]
32 pub struct MagicSignature: u32 {83 pub struct MagicSignature: u32 {
33 /// Matches patterns from the root of the repository84 /// Matches patterns from the root of the repository
34 const TOP = 1 << 0;85 const TOP = 1 << 0;
@@ -36,12 +87,15 @@ bitflags! { · 1 unmodified line
36 const ICASE = 1 << 1;87 const ICASE = 1 << 1;
37 /// Excludes the matching patterns from the previous results88 /// Excludes the matching patterns from the previous results
38 const EXCLUDE = 1 << 2;89 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;
39 }93 }
40}94}
41 95
42/// Parts of [magic signatures][MagicSignature] which don't stack as they all configure96/// Parts of [magic signatures][MagicSignature] which don't stack as they all configure
43/// the way path specs are matched.97/// 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)]
45pub enum MatchMode {99pub enum MatchMode {
46 /// Expand special characters like `*` similar to how the shell would do it.100 /// Expand special characters like `*` similar to how the shell would do it.
47 ///101 ///
@@ -54,7 +108,10 @@ pub enum MatchMode { · 6 unmodified lines
54 PathAwareGlob,108 PathAwareGlob,
55}109}
56 110
57/// Parse a git-style pathspec into a [`Pattern`][Pattern].111/// Parse a git-style pathspec into a [`Pattern`][Pattern],
58pub fn parse(input: &[u8]) -> Result<Pattern, parse::Error> {112/// setting the given `default` values in case these aren't specified in `input`.
59 Pattern::from_bytes(input)113///
114/// Note that empty [paths](Pattern::path) are allowed here, and generally some processing has to be performed.
115pub fn parse(input: &[u8], default: parse::Defaults) -> Result<Pattern, parse::Error> {
116 Pattern::from_bytes(input, default)
60}117}
gix-pathspec/src/parse.rs+52−8
@@ -4,6 +4,21 @@ use bstr::{BStr, BString, ByteSlice, ByteVec}; · 3 unmodified lines
4 4
5use crate::{MagicSignature, MatchMode, Pattern};5use crate::{MagicSignature, MatchMode, Pattern};
6 6
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)]
11pub 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
7/// The error returned by [parse()][crate::parse()].22/// The error returned by [parse()][crate::parse()].
8#[derive(thiserror::Error, Debug)]23#[derive(thiserror::Error, Debug)]
9#[allow(missing_docs)]24#[allow(missing_docs)]
@@ -32,16 +47,31 @@ pub enum Error { · 22 unmodified lines
32 47
33impl Pattern {48impl Pattern {
34 /// Try to parse a path-spec pattern from the given `input` bytes.49 /// 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> {
36 if input.is_empty() {58 if input.is_empty() {
37 return Err(Error::EmptyString);59 return Err(Error::EmptyString);
38 }60 }
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 }
39 70
40 let mut p = Pattern {71 let mut p = Pattern {
41 path: BString::default(),72 signature,
42 signature: MagicSignature::empty(),73 search_mode,
43 search_mode: MatchMode::ShellGlob,74 ..Default::default()
44 attributes: Vec::new(),
45 };75 };
46 76
47 let mut cursor = 0;77 let mut cursor = 0;
@@ -54,9 +84,25 @@ impl Pattern { · 6 unmodified lines
54 }84 }
55 }85 }
56 86
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();
58 Ok(p)93 Ok(p)
59 }94 }
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 }
60}106}
61 107
62fn parse_short_keywords(input: &[u8], cursor: &mut usize) -> Result<MagicSignature, Error> {108fn 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
90 let input = &input[*cursor..end];136 let input = &input[*cursor..end];
91 *cursor = end + 1;137 *cursor = end + 1;
92 138
93 debug_assert_eq!(p.search_mode, MatchMode::default());
94
95 if input.is_empty() {139 if input.is_empty() {
96 return Ok(());140 return Ok(());
97 }141 }
gix-pathspec/src/pattern.rs+192−0
@@ -0,0 +1,192 @@
1use crate::{normalize, MagicSignature, MatchMode, Pattern};
2use bstr::{BStr, BString, ByteSlice, ByteVec};
3use std::path::{Component, Path, PathBuf};
4
5/// Access
6impl 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
31impl 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
137impl 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
188impl 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 @@
1use crate::search::Spec;
2use crate::{MagicSignature, Pattern, Search};
3use std::path::Path;
4
5/// Create a new specification to support matches from `pathspec`, [normalizing](Pattern::normalize()) it with `prefix` and `root`.
6fn 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
50fn 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
86impl 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 @@
1use crate::search::{Match, Spec};
2use crate::{MagicSignature, MatchMode, Pattern, Search};
3use bstr::{BStr, BString, ByteSlice};
4use gix_glob::pattern::Case;
5
6impl 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
120fn 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 @@
1use crate::{Pattern, Search};
2use bstr::{BStr, ByteSlice};
3
4/// Describes a matching pattern within a search for ignored paths.
5#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
6pub 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
13mod init;
14
15impl 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
23impl 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)]
42pub(crate) struct Spec {
43 pub pattern: Pattern,
44 pub attrs_match: Option<gix_attributes::search::Outcome>,
45}
46
47mod matching;
gix-pathspec/tests/fixtures/generated-archives/generate_pathspec_baseline.tar.xz+0−3
@@ -1,3 +0,0 @@
1version https://git-lfs.github.com/spec/v1
2oid sha256:505e0eed20808737e0f3f8b993e54e3b2b9b9d124e1615a003336ce34c60b939
3size 9484
gix-pathspec/tests/fixtures/generated-archives/match_baseline.tar.xz+3−0
@@ -0,0 +1,3 @@
1version https://git-lfs.github.com/spec/v1
2oid sha256:0ba46801dfab65bcc77c8ce0dc9144d77fd142be847f3340af6ae8fcba44c82d
3size 16928
gix-pathspec/tests/fixtures/generated-archives/match_baseline_dirs.tar.xz+3−0
@@ -0,0 +1,3 @@
1version https://git-lfs.github.com/spec/v1
2oid sha256:343ad9b362536abf88d6fc4519b6532e53e53f8a1b94436c9ec3f88b6874e4df
3size 15972
gix-pathspec/tests/fixtures/generated-archives/match_baseline_files.tar.xz+3−0
@@ -0,0 +1,3 @@
1version https://git-lfs.github.com/spec/v1
2oid sha256:b0c722ab08de2ab63b9f222747cf898f5b6bb6b76ec55ccd176bab3b854ef7bb
3size 10700
gix-pathspec/tests/fixtures/generated-archives/parse_baseline.tar.xz+3−0
@@ -0,0 +1,3 @@
1version https://git-lfs.github.com/spec/v1
2oid sha256:123da727b33802a3a2dc80e948b07cbf133c2d66129693f4896cba2f6b92b02f
3size 9528
gix-pathspec/tests/fixtures/match_baseline_dirs.sh+86−0
@@ -0,0 +1,86 @@
1#!/bin/bash
2set -eu -o pipefail
3
4git init;
5git init sub
6(cd sub
7 : >empty
8 git add empty
9 git commit -m init-for-submodule
10)
11
12git 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
2set -eu -o pipefail
3
4git init;
5
6function 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
26echo 'goo a !b c=v -d' > .gitattributes
27mkdir sub && :>sub/bar
28git add . && git commit -m init
29# this is to avoid issues on windows, which might not be able to manifest these files.
30git -c core.protectNTFS=false update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" "g*"
31git update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" "!a"
32for 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"
34done
35git -c core.ignoreCase=false update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" " " # 4 x space
36git -c core.ignoreCase=false update-index --add --cacheinfo 100644 "$(git rev-parse HEAD:goo)" " hi " # 2 x space hi 2 x space
37
38git ls-files > paths
39
40baseline ':(attr:a)goo'
41baseline ':(attr:a)Goo'
42baseline ':(icase,attr:a)Goo'
43baseline ':(attr:!b)goo'
44baseline ':(attr:c=v)goo'
45baseline ':(attr:-d)goo'
46baseline ':(attr:a !b c=v -d)goo'
47baseline ':(icase,attr:a !b c=v -d)GOO'
48baseline ':(attr:a !b c=v -d)g*'
49baseline ':(attr:none)goo'
50baseline ':(literal)g*'
51baseline 'sub/'
52baseline 'sub'
53baseline 'sub/*'
54baseline 'sub*'
55baseline ':(literal)g*'
56baseline ':(glob)g*'
57baseline ':(exclude,literal)g*'
58baseline 'g*'
59baseline ':(exclude)g*'
60baseline ':(literal)?*'
61baseline ':(exclude,literal)?*'
62baseline '?*'
63baseline ':(exclude)?*'
64baseline 'g[o][o]'
65# ["g[o][o]", "goo"] == ["g[o][o]"]
66baseline ':(icase)G[O][o]' git-inconsistency
67baseline ':(literal)g[o][o]'
68baseline ':(literal,icase)G[o][O]'
69baseline ':(glob)g[o][o]'
70# ["g[o][o]", "goo"] == ["g[o][o]"]
71baseline ':(glob,icase)g[o][O]' git-inconsistency
72baseline ':(glob)**/bar'
73baseline ':(literal)**/bar'
74baseline '**/bar'
75baseline '*/bar'
76baseline ':(glob)*bar'
77baseline ':(glob)**bar'
78baseline '*bar'
79baseline '*bar*'
80baseline 'bar'
81baseline 'bar/'
82baseline 'sub/bar/'
83baseline 'sub/bar'
84baseline '!a'
85baseline '?a'
86baseline 'foo/'
87baseline 'foo'
88baseline 'foo/*'
89baseline 'foo*'
90baseline ':(icase)foo/'
91baseline ':(icase)foo'
92baseline ':(icase)foo/*'
93baseline ':(icase)foo*'
94baseline ':(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
28 28
29# empty_signatures29# empty_signatures
30baseline '.'30baseline '.'
31baseline ':'
31baseline 'some/path'32baseline 'some/path'
32baseline ':some/path'33baseline ':some/path'
33baseline ':()some/path'34baseline ':()some/path'
@@ -47,8 +48,11 @@ baseline ':! some/path' · 13 unmodified lines
47 48
48# short_signatures49# short_signatures
49baseline ':/some/path'50baseline ':/some/path'
51baseline '://some/path'
50baseline ':^some/path'52baseline ':^some/path'
53baseline ':^^some/path'
51baseline ':!some/path'54baseline ':!some/path'
55baseline ':!!some/path'
52baseline ':/!some/path'56baseline ':/!some/path'
53baseline ':!/^/:some/path'57baseline ':!/^/:some/path'
54 58
@@ -143,3 +147,12 @@ baseline ':(top' · 88 unmodified lines
143 147
144# glob_and_literal_keywords_present148# glob_and_literal_keywords_present
145baseline ':(glob,literal)some/path'149baseline ':(glob,literal)some/path'
150# trailing slash
151baseline ':(glob,literal)some/path/'
152baseline 'some/path/'
153baseline 'path/'
154
155baseline 'a/b/'
156baseline 'a/'
157baseline '!a'
158baseline '\!a'
gix-pathspec/tests/normalize/mod.rs+116−0
@@ -0,0 +1,116 @@
1use std::path::Path;
2
3#[test]
4fn 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]
32fn 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]
42fn 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]
63fn 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]
71fn 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]
79fn 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]
93fn 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]
101fn 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
108fn 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 @@
1use gix_pathspec::parse::Error;
2
3use crate::parse::check_against_baseline;
4
5#[test]
6fn 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]
17fn 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]
33fn 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]
51fn 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]
69fn 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]
94fn 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]
111fn 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]
122fn 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]
133fn 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]
144fn 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 @@
1use std::collections::HashMap;
2
3use bstr::{BStr, BString, ByteSlice};
4use gix_attributes::State;
5use gix_pathspec::{MagicSignature, MatchMode, Pattern};
6use once_cell::sync::Lazy;
7
8#[test]
9fn 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
30mod invalid;
31mod valid;
32
33/// A way to specify expectations more easily by simplifying assignments.
34#[derive(Debug, Clone, PartialEq, Eq)]
35struct NormalizedPattern {
36 path: BString,
37 signature: MagicSignature,
38 search_mode: MatchMode,
39 attributes: Vec<(BString, State)>,
40}
41
42impl 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
57static 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
73fn 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
87fn 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 @@
1use gix_attributes::State;
2use gix_pathspec::{MagicSignature, MatchMode};
3
4use crate::parse::{check_against_baseline, check_valid_inputs, NormalizedPattern};
5
6#[test]
7fn 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]
21fn glob_negations_are_always_literal() {
22 check_valid_inputs([("!a", pat_with_path("!a")), ("\\!a", pat_with_path("\\!a"))]);
23}
24
25#[test]
26fn 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]
67fn 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]
88fn 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]
104fn 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]
119fn 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]
142fn 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]
167fn 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]
175fn 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]
219fn 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]
263fn 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
294fn pat_with_path(path: &str) -> NormalizedPattern {
295 pat_with_path_and_sig(path, MagicSignature::empty())
296}
297
298fn pat_with_path_and_sig(path: &str, signature: MagicSignature) -> NormalizedPattern {
299 pat(path, signature, MatchMode::ShellGlob, vec![])
300}
301
302fn pat_with_sig(signature: MagicSignature) -> NormalizedPattern {
303 pat("", signature, MatchMode::ShellGlob, vec![])
304}
305
306fn pat_with_attrs(attrs: Vec<(&'static str, State)>) -> NormalizedPattern {
307 pat("", MagicSignature::empty(), MatchMode::ShellGlob, attrs)
308}
309
310fn pat_with_search_mode(search_mode: MatchMode) -> NormalizedPattern {
311 pat("", MagicSignature::empty(), search_mode, vec![])
312}
313
314fn 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 @@
1pub use gix_testtools::Result;1pub use gix_testtools::Result;
2 2
3mod parse {3mod normalize;
4 use std::collections::HashMap;4mod parse;
5 5mod search;
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}
gix-pathspec/tests/search/mod.rs+257−0
@@ -0,0 +1,257 @@
1use std::path::Path;
2
3#[test]
4fn directories() -> crate::Result {
5 baseline::run("directory", true, baseline::directories)
6}
7
8#[test]
9fn 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]
21fn 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]
47fn 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]
99fn 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]
121fn files() -> crate::Result {
122 baseline::run("file", false, baseline::files)
123}
124
125mod 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
164gix-worktree = { version = "^0.24.0", path = "../gix-worktree" }164gix-worktree = { version = "^0.24.0", path = "../gix-worktree" }
165gix-hashtable = { version = "^0.2.4", path = "../gix-hashtable" }165gix-hashtable = { version = "^0.2.4", path = "../gix-hashtable" }
166gix-commitgraph = { version = "^0.18.2", path = "../gix-commitgraph" }166gix-commitgraph = { version = "^0.18.2", path = "../gix-commitgraph" }
167gix-pathspec = { version = "^0.1.0", path = "../gix-pathspec" }
167 168
168gix-worktree-stream = { version = "^0.3.0", path = "../gix-worktree-stream", optional = true }169gix-worktree-stream = { version = "^0.3.0", path = "../gix-worktree-stream", optional = true }
169gix-archive = { version = "^0.3.0", path = "../gix-archive", default-features = false, optional = true }170gix-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
99pub use gix_object as objs;99pub use gix_object as objs;
100pub use gix_object::bstr;100pub use gix_object::bstr;
101pub use gix_odb as odb;101pub use gix_odb as odb;
102pub use gix_pathspec as pathspec;
102pub use gix_prompt as prompt;103pub use gix_prompt as prompt;
103#[cfg(feature = "gix-protocol")]104#[cfg(feature = "gix-protocol")]
104pub use gix_protocol as protocol;105pub use gix_protocol as protocol;
gix/src/repository/location.rs+23−20
@@ -45,27 +45,30 @@ impl crate::Repository { · 44 unmodified lines
45 /// Note that there may be `None` if there is no work tree, even though the `PathBuf` will be empty45 /// Note that there may be `None` if there is no work tree, even though the `PathBuf` will be empty
46 /// if the CWD is at the root of the work tree.46 /// if the CWD is at the root of the work tree.
47 // TODO: tests, details - there is a lot about environment variables to change things around.47 // TODO: tests, details - there is a lot about environment variables to change things around.
48 pub fn prefix(&self) -> Option<std::io::Result<PathBuf>> {48 pub fn prefix(&self) -> std::io::Result<Option<PathBuf>> {
49 self.work_tree.as_ref().map(|root| {49 self.work_tree
50 std::env::current_dir().and_then(|cwd| {50 .as_ref()
51 gix_path::realpath_opts(root, &cwd, MAX_SYMLINKS)51 .map(|root| {
52 .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))52 std::env::current_dir().and_then(|cwd| {
53 .and_then(|root| {53 gix_path::realpath_opts(root, &cwd, MAX_SYMLINKS)
54 cwd.strip_prefix(&root)54 .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
55 .map_err(|_| {55 .and_then(|root| {
56 std::io::Error::new(56 cwd.strip_prefix(&root)
57 std::io::ErrorKind::Other,57 .map_err(|_| {
58 format!(58 std::io::Error::new(
59 "CWD '{}' isn't within the work tree '{}'",59 std::io::ErrorKind::Other,
60 cwd.display(),60 format!(
61 root.display()61 "CWD '{}' isn't within the work tree '{}'",
62 ),62 cwd.display(),
63 )63 root.display()
64 })64 ),
65 .map(ToOwned::to_owned)65 )
66 })66 })
67 .map(ToOwned::to_owned)
68 })
69 })
67 })70 })
68 })71 .transpose()
69 }72 }
70 73
71 /// Return the kind of repository, either bare or one with a work tree.74 /// 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
1018 ),1018 ),
1019 },1019 },
1020 Subcommands::Attributes(cmd) => match cmd {1020 Subcommands::Attributes(cmd) => match cmd {
1021 attributes::Subcommands::Query { statistics, pathspecs } => prepare_and_run(1021 attributes::Subcommands::Query { statistics, pathspec } => prepare_and_run(
1022 "attributes-query",1022 "attributes-query",
1023 trace,1023 trace,
1024 verbose,1024 verbose,
@@ -1029,15 +1029,17 @@ pub fn main() -> Result<()> { · 4 unmodified lines
1029 use gix::bstr::ByteSlice;1029 use gix::bstr::ByteSlice;
1030 core::repository::attributes::query(1030 core::repository::attributes::query(
1031 repository(Mode::Strict)?,1031 repository(Mode::Strict)?,
1032 if pathspecs.is_empty() {1032 if pathspec.is_empty() {
1033 Box::new(1033 Box::new(stdin_or_bail()?.byte_lines().filter_map(Result::ok).filter_map(|line| {
1034 stdin_or_bail()?1034 gix::pathspec::parse(
1035 .byte_lines()1035 line.as_bstr(),
1036 .filter_map(Result::ok)1036 // TODO(pathspec): use `repo` actual global defaults when available (see following as well)
1037 .filter_map(|line| gix::path::Spec::from_bytes(line.as_bstr())),1037 Default::default(),
1038 ) as Box<dyn Iterator<Item = gix::path::Spec>>1038 )
1039 .ok()
1040 })) as Box<dyn Iterator<Item = gix::pathspec::Pattern>>
1039 } else {1041 } else {
1040 Box::new(pathspecs.into_iter())1042 Box::new(pathspec.into_iter())
1041 },1043 },
1042 out,1044 out,
1043 err,1045 err,
@@ -1053,15 +1055,11 @@ pub fn main() -> Result<()> { · 9 unmodified lines
1053 progress_keep_open,1055 progress_keep_open,
1054 None,1056 None,
1055 move |progress, out, err| {1057 move |progress, out, err| {
1056 use gix::bstr::ByteSlice;
1057 core::repository::attributes::validate_baseline(1058 core::repository::attributes::validate_baseline(
1058 repository(Mode::StrictWithGitInstallConfig)?,1059 repository(Mode::StrictWithGitInstallConfig)?,
1059 stdin_or_bail().ok().map(|stdin| {1060 stdin_or_bail()
1060 stdin1061 .ok()
1061 .byte_lines()1062 .map(|stdin| stdin.byte_lines().filter_map(Result::ok).map(gix::bstr::BString::from)),
1062 .filter_map(Result::ok)
1063 .filter_map(|line| gix::path::Spec::from_bytes(line.as_bstr()))
1064 }),
1065 progress,1063 progress,
1066 out,1064 out,
1067 err,1065 err,
@@ -1078,7 +1076,7 @@ pub fn main() -> Result<()> { · 10 unmodified lines
1078 exclude::Subcommands::Query {1076 exclude::Subcommands::Query {
1079 statistics,1077 statistics,
1080 patterns,1078 patterns,
1081 pathspecs,1079 pathspec,
1082 show_ignore_patterns,1080 show_ignore_patterns,
1083 } => prepare_and_run(1081 } => prepare_and_run(
1084 "exclude-query",1082 "exclude-query",
@@ -1088,18 +1086,17 @@ pub fn main() -> Result<()> { · 3 unmodified lines
1088 progress_keep_open,1086 progress_keep_open,
1089 None,1087 None,
1090 move |_progress, out, err| {1088 move |_progress, out, err| {
1091 use gix::bstr::ByteSlice;
1092 core::repository::exclude::query(1089 core::repository::exclude::query(
1093 repository(Mode::Strict)?,1090 repository(Mode::Strict)?,
1094 if pathspecs.is_empty() {1091 if pathspec.is_empty() {
1095 Box::new(1092 Box::new(
1096 stdin_or_bail()?1093 stdin_or_bail()?
1097 .byte_lines()1094 .byte_lines()
1098 .filter_map(Result::ok)1095 .filter_map(Result::ok)
1099 .filter_map(|line| gix::path::Spec::from_bytes(line.as_bstr())),1096 .filter_map(|line| gix::pathspec::parse(&line, Default::default()).ok()),
1100 ) as Box<dyn Iterator<Item = gix::path::Spec>>1097 ) as Box<dyn Iterator<Item = gix::pathspec::Pattern>>
1101 } else {1098 } else {
1102 Box::new(pathspecs.into_iter())1099 Box::new(pathspec.into_iter())
1103 },1100 },
1104 out,1101 out,
1105 err,1102 err,
@@ -1115,9 +1112,11 @@ pub fn main() -> Result<()> { · 9 unmodified lines
1115 },1112 },
1116 Subcommands::Index(cmd) => match cmd {1113 Subcommands::Index(cmd) => match cmd {
1117 index::Subcommands::Entries {1114 index::Subcommands::Entries {
1115 format: entry_format,
1118 no_attributes,1116 no_attributes,
1119 attributes_from_index,1117 attributes_from_index,
1120 statistics,1118 statistics,
1119 pathspec,
1121 } => prepare_and_run(1120 } => prepare_and_run(
1122 "index-entries",1121 "index-entries",
1123 trace,1122 trace,
@@ -1128,10 +1127,15 @@ pub fn main() -> Result<()> { · 4 unmodified lines
1128 move |_progress, out, err| {1127 move |_progress, out, err| {
1129 core::repository::index::entries(1128 core::repository::index::entries(
1130 repository(Mode::LenientWithGitInstallConfig)?,1129 repository(Mode::LenientWithGitInstallConfig)?,
1130 pathspec,
1131 out,1131 out,
1132 err,1132 err,
1133 core::repository::index::entries::Options {1133 core::repository::index::entries::Options {
1134 format,1134 format,
1135 simple: match entry_format {
1136 index::entries::Format::Simple => true,
1137 index::entries::Format::Rich => false,
1138 },
1135 attributes: if no_attributes {1139 attributes: if no_attributes {
1136 None1140 None
1137 } else {1141 } else {
src/plumbing/options/mod.rs+20−2
@@ -610,7 +610,7 @@ pub mod attributes { · 609 unmodified lines
610 statistics: bool,610 statistics: bool,
611 /// The git path specifications to list attributes for, or unset to read from stdin one per line.611 /// The git path specifications to list attributes for, or unset to read from stdin one per line.
612 #[clap(value_parser = AsPathSpec)]612 #[clap(value_parser = AsPathSpec)]
613 pathspecs: Vec<gix::path::Spec>,613 pathspec: Vec<gix::pathspec::Pattern>,
614 },614 },
615 }615 }
616}616}
@@ -639,18 +639,33 @@ pub mod exclude { · 22 unmodified lines
639 patterns: Vec<OsString>,639 patterns: Vec<OsString>,
640 /// The git path specifications to check for exclusion, or unset to read from stdin one per line.640 /// The git path specifications to check for exclusion, or unset to read from stdin one per line.
641 #[clap(value_parser = AsPathSpec)]641 #[clap(value_parser = AsPathSpec)]
642 pathspecs: Vec<gix::path::Spec>,642 pathspec: Vec<gix::pathspec::Pattern>,
643 },643 },
644 }644 }
645}645}
646 646
647pub mod index {647pub mod index {
648 use crate::shared::AsPathSpec;
648 use std::path::PathBuf;649 use std::path::PathBuf;
649 650
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
650 #[derive(Debug, clap::Subcommand)]662 #[derive(Debug, clap::Subcommand)]
651 pub enum Subcommands {663 pub enum Subcommands {
652 /// Print all entries to standard output664 /// Print all entries to standard output
653 Entries {665 Entries {
666 /// How to output index entries.
667 #[clap(long, short = 'f', default_value = "simple", value_enum)]
668 format: entries::Format,
654 /// Do not visualize excluded entries or attributes per path.669 /// Do not visualize excluded entries or attributes per path.
655 #[clap(long)]670 #[clap(long)]
656 no_attributes: bool,671 no_attributes: bool,
@@ -662,6 +677,9 @@ pub mod index { · 5 unmodified lines
662 /// Print various statistics to stderr677 /// Print various statistics to stderr
663 #[clap(long, short = 's')]678 #[clap(long, short = 's')]
664 statistics: bool,679 statistics: bool,
680 /// The git path specifications to match entries to print.
681 #[clap(value_parser = AsPathSpec)]
682 pathspec: Vec<gix::pathspec::Pattern>,
665 },683 },
666 /// Create an index from a tree-ish.684 /// Create an index from a tree-ish.
667 #[clap(visible_alias = "read-tree")]685 #[clap(visible_alias = "read-tree")]
src/porcelain/options.rs+1−1
@@ -125,7 +125,7 @@ pub mod tools { · 124 unmodified lines
125 TracePath {125 TracePath {
126 /// The path to trace through history.126 /// The path to trace through history.
127 #[clap(value_parser = AsPathSpec)]127 #[clap(value_parser = AsPathSpec)]
128 path: gix::path::Spec,128 path: gix::pathspec::Pattern,
129 },129 },
130 }130 }
131 }131 }
src/shared.rs+10−2
@@ -349,11 +349,19 @@ mod clap { · 348 unmodified lines
349 pub struct AsPathSpec;349 pub struct AsPathSpec;
350 350
351 impl TypedValueParser for AsPathSpec {351 impl TypedValueParser for AsPathSpec {
352 type Value = gix::path::Spec;352 type Value = gix::pathspec::Pattern;
353 353
354 fn parse_ref(&self, cmd: &Command, arg: Option<&Arg>, value: &OsStr) -> Result<Self::Value, Error> {354 fn parse_ref(&self, cmd: &Command, arg: Option<&Arg>, value: &OsStr) -> Result<Self::Value, Error> {
355 OsStringValueParser::new()355 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 })
357 .parse_ref(cmd, arg, value)365 .parse_ref(cmd, arg, value)
358 }366 }
359 }367 }