Skip to content

Commit 57f0a24

Browse files
committed
feat: Add Search::prefix_directory() and Search::longest_common_directory().
That way it's possible to use the common-prefix safely in a situation where a directory is required, while offering the ability to maximize the common prefix at the expense of an additional check to see if the longest possible directory is actually accessible.
1 parent 366dfb3 commit 57f0a24

File tree

2 files changed

+118
-20
lines changed

2 files changed

+118
-20
lines changed

gix-pathspec/src/search/mod.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use bstr::{BStr, ByteSlice};
2+
use std::borrow::Cow;
3+
use std::path::Path;
24

3-
use crate::{Pattern, Search};
5+
use crate::{MagicSignature, Pattern, Search};
46

57
/// Describes a matching pattern within a search for ignored paths.
68
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
@@ -56,6 +58,44 @@ impl Search {
5658
.find(|p| !p.value.pattern.is_excluded())
5759
.map_or("".into(), |m| m.value.pattern.path[..self.common_prefix_len].as_bstr())
5860
}
61+
62+
/// Returns a guaranteed-to-be-directory that is shared across all pathspecs, in its repository-relative form.
63+
/// Thus to be valid, it must be joined with the worktree root.
64+
/// The prefix is the CWD within a worktree passed when [normalizing](crate::Pattern::normalize) the pathspecs.
65+
///
66+
/// Note that it may well be that the directory isn't available even though there is a [`common_prefix()`](Self::common_prefix),
67+
/// as they are not quire the same.
68+
///
69+
/// See also: [`maybe_prefix_directory()`](Self::longest_common_directory).
70+
pub fn prefix_directory(&self) -> Cow<'_, Path> {
71+
gix_path::from_bstr(
72+
self.patterns
73+
.iter()
74+
.find(|p| !p.value.pattern.is_excluded())
75+
.map_or("".into(), |m| m.value.pattern.prefix_directory()),
76+
)
77+
}
78+
79+
/// Return the longest possible common directory that is shared across all non-exclusive pathspecs.
80+
/// It must be tested for existence by joining it with a suitable root before being able to use it.
81+
/// Note that if it is returned, it's guaranteed to be longer than the [prefix-directory](Self::prefix_directory).
82+
///
83+
/// Returns `None` if the returned directory would be empty, or if all pathspecs are exclusive.
84+
pub fn longest_common_directory(&self) -> Option<Cow<'_, Path>> {
85+
let first_non_excluded = self.patterns.iter().find(|p| !p.value.pattern.is_excluded())?;
86+
let common_prefix = first_non_excluded.value.pattern.path[..self.common_prefix_len].as_bstr();
87+
let stripped_prefix = if first_non_excluded
88+
.value
89+
.pattern
90+
.signature
91+
.contains(MagicSignature::MUST_BE_DIR)
92+
{
93+
common_prefix
94+
} else {
95+
common_prefix[..common_prefix.rfind_byte(b'/')?].as_bstr()
96+
};
97+
Some(gix_path::from_bstr(stripped_prefix))
98+
}
5999
}
60100

61101
#[derive(Default, Clone, Debug)]

gix-pathspec/tests/search/mod.rs

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,40 @@ fn simplified_search_handles_nil() -> crate::Result {
330330
Ok(())
331331
}
332332

333+
#[test]
334+
fn longest_common_directory_no_prefix() -> crate::Result {
335+
let search = gix_pathspec::Search::from_specs(pathspecs(&["tests/a/", "tests/b/", ":!*.sh"]), None, Path::new(""))?;
336+
assert_eq!(search.common_prefix(), "tests/");
337+
assert_eq!(search.prefix_directory(), Path::new(""));
338+
assert_eq!(
339+
search.longest_common_directory().expect("present").to_string_lossy(),
340+
"tests/",
341+
"trailing slashes are not stripped"
342+
);
343+
Ok(())
344+
}
345+
346+
#[test]
347+
fn longest_common_directory_with_prefix() -> crate::Result {
348+
let search = gix_pathspec::Search::from_specs(
349+
pathspecs(&["tests/a/", "tests/b/", ":!*.sh"]),
350+
Some(Path::new("a/b")),
351+
Path::new(""),
352+
)?;
353+
assert_eq!(search.common_prefix(), "a/b/tests/");
354+
assert_eq!(
355+
search.prefix_directory().to_string_lossy(),
356+
"a/b",
357+
"trailing slashes are not contained"
358+
);
359+
assert_eq!(
360+
search.longest_common_directory().expect("present").to_string_lossy(),
361+
"a/b/tests/",
362+
"trailing slashes are present, they don't matter"
363+
);
364+
Ok(())
365+
}
366+
333367
#[test]
334368
fn init_with_exclude() -> crate::Result {
335369
let search = gix_pathspec::Search::from_specs(pathspecs(&["tests/", ":!*.sh"]), None, Path::new(""))?;
@@ -339,6 +373,16 @@ fn init_with_exclude() -> crate::Result {
339373
"re-orded so that excluded are first"
340374
);
341375
assert_eq!(search.common_prefix(), "tests");
376+
assert_eq!(
377+
search.prefix_directory(),
378+
Path::new(""),
379+
"there was no prefix during initialization"
380+
);
381+
assert_eq!(
382+
search.longest_common_directory(),
383+
Some(Path::new("tests").into()),
384+
"but this works here, and it should be tested"
385+
);
342386
assert!(
343387
search.can_match_relative_path("tests".into(), Some(true)),
344388
"prefix matches"
@@ -388,40 +432,49 @@ fn prefixes_are_always_case_sensitive() -> crate::Result {
388432
let path = gix_testtools::scripted_fixture_read_only("match_baseline_files.sh")?.join("paths");
389433
let items = baseline::parse_paths(path)?;
390434

391-
for (spec, prefix, common_prefix, expected) in [
392-
(":(icase)bar", "FOO", "FOO", &["FOO/BAR", "FOO/bAr", "FOO/bar"] as &[_]),
393-
(":(icase)bar", "F", "F", &[]),
394-
(":(icase)bar", "FO", "FO", &[]),
395-
(":(icase)../bar", "fOo", "", &["BAR", "bAr", "bar"]),
396-
("../bar", "fOo", "bar", &["bar"]),
397-
(" ", "", " ", &[" "]), // whitespace can match verbatim
398-
(" hi*", "", " hi", &[" hi "]), // whitespace can match with globs as well
399-
(":(icase)../bar", "fO", "", &["BAR", "bAr", "bar"]), // prefixes are virtual, and don't have to exist at all.
435+
for (spec, prefix, common_prefix, expected, expected_common_dir) in [
436+
(
437+
":(icase)bar",
438+
"FOO",
439+
"FOO",
440+
&["FOO/BAR", "FOO/bAr", "FOO/bar"] as &[_],
441+
"FOO",
442+
),
443+
(":(icase)bar", "F", "F", &[], "F"),
444+
(":(icase)bar", "FO", "FO", &[], "FO"),
445+
(":(icase)../bar", "fOo", "", &["BAR", "bAr", "bar"], ""),
446+
("../bar", "fOo", "bar", &["bar"], ""),
447+
(" ", "", " ", &[" "], ""), // whitespace can match verbatim
448+
(" hi*", "", " hi", &[" hi "], ""), // whitespace can match with globs as well
449+
(":(icase)../bar", "fO", "", &["BAR", "bAr", "bar"], ""), // prefixes are virtual, and don't have to exist at all.
400450
(
401451
":(icase)../foo/bar",
402452
"FOO",
403453
"",
404454
&[
405455
"FOO/BAR", "FOO/bAr", "FOO/bar", "fOo/BAR", "fOo/bAr", "fOo/bar", "foo/BAR", "foo/bAr", "foo/bar",
406456
],
457+
"",
407458
),
408-
("../foo/bar", "FOO", "foo/bar", &["foo/bar"]),
459+
("../foo/bar", "FOO", "foo/bar", &["foo/bar"], ""),
409460
(
410461
":(icase)../foo/../fOo/bar",
411462
"FOO",
412463
"",
413464
&[
414465
"FOO/BAR", "FOO/bAr", "FOO/bar", "fOo/BAR", "fOo/bAr", "fOo/bar", "foo/BAR", "foo/bAr", "foo/bar",
415466
],
467+
"",
416468
),
417-
("../foo/../fOo/BAR", "FOO", "fOo/BAR", &["fOo/BAR"]),
469+
("../foo/../fOo/BAR", "FOO", "fOo/BAR", &["fOo/BAR"], ""),
418470
] {
419471
let mut search = gix_pathspec::Search::from_specs(
420472
gix_pathspec::parse(spec.as_bytes(), Default::default()),
421473
Some(Path::new(prefix)),
422474
Path::new(""),
423475
)?;
424476
assert_eq!(search.common_prefix(), common_prefix, "{spec} {prefix}");
477+
assert_eq!(search.prefix_directory(), Path::new(expected_common_dir));
425478
let actual: Vec<_> = items
426479
.iter()
427480
.filter(|relative_path| {
@@ -453,13 +506,13 @@ fn prefixes_are_always_case_sensitive() -> crate::Result {
453506

454507
#[test]
455508
fn common_prefix() -> crate::Result {
456-
for (specs, prefix, expected) in [
457-
(&["foo/bar", ":(icase)foo/bar"] as &[_], None, ""),
458-
(&["foo/bar", "foo"], None, "foo"),
459-
(&["foo/bar/baz", "foo/bar/"], None, "foo/bar"), // directory trailing slashes are ignored, but that prefix shouldn't care anyway
460-
(&[":(icase)bar", ":(icase)bart"], Some("foo"), "foo"), // only case-sensitive portions count
461-
(&["bar", "bart"], Some("foo"), "foo/bar"), // otherwise everything that matches counts
462-
(&["bar", "bart", "ba"], Some("foo"), "foo/ba"),
509+
for (specs, prefix, expected_common_prefix, expected_common_dir) in [
510+
(&["foo/bar", ":(icase)foo/bar"] as &[_], None, "", ""),
511+
(&["foo/bar", "foo"], None, "foo", ""),
512+
(&["foo/bar/baz", "foo/bar/"], None, "foo/bar", ""), // directory trailing slashes are ignored, but that prefix shouldn't care anyway
513+
(&[":(icase)bar", ":(icase)bart"], Some("foo"), "foo", "foo"), // only case-sensitive portions count
514+
(&["bar", "bart"], Some("foo"), "foo/bar", "foo"), // otherwise everything that matches counts
515+
(&["bar", "bart", "ba"], Some("foo"), "foo/ba", "foo"),
463516
] {
464517
let search = gix_pathspec::Search::from_specs(
465518
specs
@@ -468,7 +521,12 @@ fn common_prefix() -> crate::Result {
468521
prefix.map(Path::new),
469522
Path::new(""),
470523
)?;
471-
assert_eq!(search.common_prefix(), expected, "{specs:?} {prefix:?}");
524+
assert_eq!(search.common_prefix(), expected_common_prefix, "{specs:?} {prefix:?}");
525+
assert_eq!(
526+
search.prefix_directory(),
527+
Path::new(expected_common_dir),
528+
"{specs:?} {prefix:?}"
529+
);
472530
}
473531
Ok(())
474532
}

0 commit comments

Comments
 (0)