use std::borrow::Cow;
use crate::crlf::path_has_gitattribute;
use crate::crlf::AttrRule;
use crate::error::{Error, Result as LibResult};
use crate::precompose_config::pathspec_precompose_enabled;
use crate::unicode_normalization::precompose_utf8_path;
use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
#[must_use]
pub fn simple_length(match_str: &str) -> usize {
let b = match_str.as_bytes();
let mut len = 0usize;
for &c in b {
if matches!(c, b'*' | b'?' | b'[' | b'\\') {
break;
}
len += 1;
}
len
}
#[must_use]
pub fn has_glob_chars(s: &str) -> bool {
simple_length(s) < s.len()
}
pub fn parse_pathspecs_from_source(data: &[u8], nul_terminated: bool) -> LibResult<Vec<String>> {
if nul_terminated {
let mut out = Vec::new();
for chunk in data.split(|b| *b == 0) {
if chunk.is_empty() {
continue;
}
let s = String::from_utf8_lossy(chunk);
let t = s.trim();
if t.starts_with('"') {
return Err(Error::PathError(format!(
"pathspec-from-file: line is not NUL terminated: {t}"
)));
}
out.push(t.to_string());
}
return Ok(out);
}
let text = String::from_utf8_lossy(data);
let mut out = Vec::new();
for raw in text.split_inclusive('\n') {
let line = raw.trim_end_matches('\n').trim_end_matches('\r');
if line.is_empty() {
continue;
}
if line.starts_with('"') && line.ends_with('"') && line.len() >= 2 {
out.push(unquote_c_style_pathspec_line(line)?);
} else {
out.push(line.to_string());
}
}
Ok(out)
}
fn unquote_c_style_pathspec_line(s: &str) -> LibResult<String> {
let bytes = s.as_bytes();
if bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') || bytes.len() < 2 {
return Err(Error::PathError(format!("invalid C-style quoting: {s}")));
}
let inner = &bytes[1..bytes.len() - 1];
let mut out = Vec::with_capacity(inner.len());
let mut i = 0;
while i < inner.len() {
if inner[i] != b'\\' {
out.push(inner[i]);
i += 1;
continue;
}
i += 1;
if i >= inner.len() {
return Err(Error::PathError(
"invalid escape at end of string".to_string(),
));
}
match inner[i] {
b'\\' => out.push(b'\\'),
b'"' => out.push(b'"'),
b'a' => out.push(7),
b'b' => out.push(8),
b'f' => out.push(12),
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'v' => out.push(11),
c if c.is_ascii_digit() => {
if i + 2 >= inner.len() {
return Err(Error::PathError("truncated octal escape".to_string()));
}
let oct = std::str::from_utf8(&inner[i..i + 3])
.map_err(|_| Error::PathError("invalid octal bytes".to_string()))?;
out.push(
u8::from_str_radix(oct, 8)
.map_err(|_| Error::PathError("invalid octal escape value".to_string()))?,
);
i += 2;
}
other => {
return Err(Error::PathError(format!(
"invalid escape sequence \\{}",
char::from(other)
)));
}
}
i += 1;
}
String::from_utf8(out).map_err(|_| Error::PathError("invalid UTF-8 in quoted pathspec".into()))
}
#[derive(Debug, Clone, Default)]
struct PathspecMagic {
literal: bool,
glob: bool,
icase: bool,
exclude: bool,
top: bool,
prefix: Option<String>,
attr_name: Option<String>,
}
fn parse_maybe_bool(v: &str) -> Option<bool> {
let s = v.trim().to_ascii_lowercase();
match s.as_str() {
"true" | "yes" | "on" | "1" => Some(true),
"false" | "no" | "off" | "0" => Some(false),
_ => None,
}
}
fn git_env_bool(key: &str, default: bool) -> bool {
match std::env::var(key) {
Ok(v) => parse_maybe_bool(&v).unwrap_or(default),
Err(_) => default,
}
}
fn literal_global() -> bool {
git_env_bool("GIT_LITERAL_PATHSPECS", false)
}
#[must_use]
pub fn literal_pathspecs_enabled() -> bool {
literal_global()
}
fn glob_global() -> bool {
git_env_bool("GIT_GLOB_PATHSPECS", false)
}
fn noglob_global() -> bool {
git_env_bool("GIT_NOGLOB_PATHSPECS", false)
}
fn icase_global() -> bool {
git_env_bool("GIT_ICASE_PATHSPECS", false)
}
pub fn validate_global_pathspec_flags() -> Result<(), String> {
let lit = literal_global();
let glob = glob_global();
let noglob = noglob_global();
let icase = icase_global();
if glob && noglob {
return Err("global 'glob' and 'noglob' pathspec settings are incompatible".to_string());
}
if lit && (glob || noglob || icase) {
return Err(
"global 'literal' pathspec setting is incompatible with all other global pathspec settings"
.to_string(),
);
}
Ok(())
}
fn parse_long_magic(rest_after_paren: &str) -> Option<(PathspecMagic, &str)> {
let close = rest_after_paren.find(')')?;
let magic_part = &rest_after_paren[..close];
let tail = &rest_after_paren[close + 1..];
let mut magic = PathspecMagic::default();
for raw in magic_part.split(',') {
let token = raw.trim();
if token.is_empty() {
continue;
}
if let Some(p) = token.strip_prefix("prefix:") {
magic.prefix = Some(p.to_string());
continue;
}
if let Some(name) = token.strip_prefix("attr:") {
if !name.is_empty() {
magic.attr_name = Some(name.to_string());
}
continue;
}
if token.eq_ignore_ascii_case("literal") {
magic.literal = true;
} else if token.eq_ignore_ascii_case("glob") {
magic.glob = true;
} else if token.eq_ignore_ascii_case("icase") {
magic.icase = true;
} else if token.eq_ignore_ascii_case("exclude") {
magic.exclude = true;
} else if token.eq_ignore_ascii_case("top") {
magic.top = true;
}
}
Some((magic, tail))
}
fn parse_short_magic(elem: &str) -> (PathspecMagic, &str) {
let bytes = elem.as_bytes();
let mut i = 1usize;
let mut magic = PathspecMagic::default();
while i < bytes.len() && bytes[i] != b':' {
let ch = bytes[i];
if ch == b'^' {
magic.exclude = true;
i += 1;
continue;
}
let is_magic = match ch {
b'!' => {
magic.exclude = true;
true
}
b'/' => {
magic.top = true;
true
} _ => false,
};
if is_magic {
i += 1;
continue;
}
break;
}
if i < bytes.len() && bytes[i] == b':' {
i += 1;
}
(magic, &elem[i..])
}
fn parse_element_magic(elem: &str) -> (PathspecMagic, &str) {
if !elem.starts_with(':') || literal_global() {
return (PathspecMagic::default(), elem);
}
if let Some(rest) = elem.strip_prefix(":(") {
return parse_long_magic(rest).unwrap_or((PathspecMagic::default(), elem));
}
parse_short_magic(elem)
}
fn combine_magic(element: PathspecMagic) -> PathspecMagic {
let mut m = element;
if literal_global() {
m.literal = true;
}
if glob_global() && !m.literal {
m.glob = true;
}
if icase_global() {
m.icase = true;
}
if noglob_global() && !m.glob {
m.literal = true;
}
m
}
fn strip_top_magic(mut pattern: &str) -> &str {
if let Some(r) = pattern.strip_prefix(":/") {
pattern = r;
}
pattern
}
#[must_use]
pub fn bloom_lookup_prefix_with_cwd(
spec: &str,
cwd_from_repo_root: Option<&str>,
) -> Option<String> {
let (elem_magic, raw_pattern) = parse_element_magic(spec);
let magic = combine_magic(elem_magic);
if magic.exclude || magic.icase {
return None;
}
let pattern = strip_top_magic(raw_pattern);
if pattern.is_empty() {
return None;
}
let combined = if magic.top {
let cwd = cwd_from_repo_root.unwrap_or("").trim_end_matches('/');
if cwd.is_empty() {
pattern.to_string()
} else {
format!("{cwd}/{pattern}")
}
} else {
pattern.to_string()
};
let pattern = combined.as_str();
let mut len = simple_length(pattern);
if len != pattern.len() {
while len > 0 && pattern.as_bytes()[len - 1] != b'/' {
len -= 1;
}
}
while len > 0 && pattern.as_bytes()[len - 1] == b'/' {
len -= 1;
}
if len == 0 {
return None;
}
Some(combined[..len].to_string())
}
#[must_use]
pub fn bloom_lookup_prefix(spec: &str) -> Option<String> {
bloom_lookup_prefix_with_cwd(spec, None)
}
#[must_use]
pub fn pathspecs_allow_bloom(specs: &[String]) -> bool {
specs.iter().all(|s| {
!s.is_empty() && !pathspec_is_exclude(s) && bloom_lookup_prefix_with_cwd(s, None).is_some()
})
}
#[must_use]
pub fn path_allowed_by_pathspec_list(specs: &[String], path: &str) -> bool {
let mut has_positive = false;
let mut positive_match = false;
for s in specs {
let (elem, raw_pattern) = parse_element_magic(s);
let magic = combine_magic(elem);
if magic.exclude {
if path_matches_pathspec_tail(raw_pattern, path, magic) {
return false;
}
continue;
}
has_positive = true;
if pathspec_matches(s, path) {
positive_match = true;
}
}
!has_positive || positive_match
}
#[must_use]
pub fn pathspec_contributes_match(spec: &str, path: &str) -> bool {
pathspec_matches(spec, path) || pathspec_exclude_matches(spec, path)
}
fn path_matches_pathspec_tail(raw_pattern: &str, path: &str, magic: PathspecMagic) -> bool {
if magic.literal && magic.glob {
return false;
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
pathspec_matches_tail(pattern, path_for_match, magic)
}
#[must_use]
pub fn pathspec_matches(spec: &str, path: &str) -> bool {
matches_pathspec(spec, path)
}
#[must_use]
pub fn pathspec_is_exclude(spec: &str) -> bool {
let (elem_magic, _) = parse_element_magic(spec);
combine_magic(elem_magic).exclude
}
#[must_use]
pub fn pathspec_wants_descent_into_tree(spec: &str, full_name: &str) -> bool {
if pathspec_is_exclude(spec) {
return false;
}
let (elem_magic, raw_pattern) = parse_element_magic(spec);
let magic = combine_magic(elem_magic);
if magic.exclude {
return false;
}
let pattern = strip_top_magic(raw_pattern);
let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
if pattern.is_empty() || pattern == "." {
return true;
}
let dir_prefix = format!("{full_name}/");
if pattern.starts_with(&dir_prefix) {
return true;
}
let probe = format!("{full_name}/.__grit_ls_tree_probe__");
matches_ls_tree_pathspec(spec, &probe, 0o100644, &[])
}
#[must_use]
pub fn matches_pathspec_set_for_object_ls_tree(
specs: &[String],
path: &str,
mode: u32,
attr_rules: &[AttrRule],
) -> bool {
if specs.is_empty() {
return true;
}
let mut positives: Vec<&str> = Vec::new();
let mut excludes: Vec<&str> = Vec::new();
for s in specs {
if pathspec_is_exclude(s) {
excludes.push(s.as_str());
} else {
positives.push(s.as_str());
}
}
let positive_ok = if positives.is_empty() {
true
} else {
positives
.iter()
.any(|s| matches_ls_tree_pathspec(s, path, mode, attr_rules))
};
if !positive_ok {
return false;
}
for ex in excludes {
if matches_ls_tree_pathspec(ex, path, mode, attr_rules) {
return false;
}
}
true
}
#[must_use]
pub fn matches_pathspec_set_for_object(
specs: &[String],
path: &str,
mode: u32,
attr_rules: &[AttrRule],
) -> bool {
if specs.is_empty() {
return true;
}
let mut positives: Vec<&str> = Vec::new();
let mut excludes: Vec<&str> = Vec::new();
for s in specs {
if pathspec_is_exclude(s) {
excludes.push(s.as_str());
} else {
positives.push(s.as_str());
}
}
let positive_ok = if positives.is_empty() {
true
} else {
positives
.iter()
.any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
};
if !positive_ok {
return false;
}
for ex in excludes {
if matches_pathspec_for_object(ex, path, mode, attr_rules) {
return false;
}
}
true
}
#[must_use]
pub fn pathspec_has_top(spec: &str) -> bool {
let (elem_magic, _) = parse_element_magic(spec);
combine_magic(elem_magic).top
}
fn pathspec_match_one_positive(path: &str, magic: PathspecMagic, raw_pattern: &str) -> bool {
if magic.literal && magic.glob {
return false;
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
pathspec_matches_tail(pattern, path_for_match, magic)
}
fn matches_pathspec_element_with_context(
spec: &str,
path: &str,
ctx: PathspecMatchContext,
) -> bool {
let (elem_magic, raw_pattern) = parse_element_magic(spec);
let magic = combine_magic(elem_magic);
if magic.exclude {
return false;
}
if magic.literal && magic.glob {
return false;
}
if magic.attr_name.is_some() {
return pathspec_matches(spec, path);
}
if magic.literal || magic.glob || magic.icase {
return pathspec_matches(spec, path);
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
matches_pathspec_with_context(pattern, path_for_match, ctx)
}
fn pathspec_exclude_element_matches_with_context(
spec: &str,
path: &str,
ctx: PathspecMatchContext,
) -> bool {
let (elem_magic, raw_pattern) = parse_element_magic(spec);
let mut magic = combine_magic(elem_magic);
if !magic.exclude {
return false;
}
magic.exclude = false;
if magic.literal && magic.glob {
return false;
}
if magic.attr_name.is_some() {
return false;
}
if magic.literal || magic.glob || magic.icase {
return pathspec_match_one_positive(path, magic, raw_pattern);
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
matches_pathspec_with_context(pattern, path_for_match, ctx)
}
#[must_use]
pub fn pathspec_exclude_matches(spec: &str, path: &str) -> bool {
pathspec_exclude_element_matches_with_context(spec, path, PathspecMatchContext::default())
}
#[must_use]
pub fn extend_pathspec_list_implicit_cwd(
specs: &[String],
cwd_from_repo_root: Option<&str>,
) -> Vec<String> {
if specs.is_empty() {
return specs.to_vec();
}
if !specs.iter().all(|s| pathspec_is_exclude(s)) {
return specs.to_vec();
}
let any_top = specs.iter().any(|s| pathspec_has_top(s));
if any_top {
return specs.to_vec();
}
let Some(cwd) = cwd_from_repo_root.map(str::trim).filter(|s| !s.is_empty()) else {
return specs.to_vec();
};
let cwd = cwd.trim_end_matches('/');
if cwd.is_empty() {
return specs.to_vec();
}
let mut out = Vec::with_capacity(specs.len() + 1);
out.push(format!("{cwd}/"));
out.extend_from_slice(specs);
out
}
#[must_use]
pub fn matches_pathspec_list(path: &str, specs: &[String]) -> bool {
matches_pathspec_list_with_context(path, specs, PathspecMatchContext::default())
}
#[must_use]
pub fn matches_pathspec_list_with_context(
path: &str,
specs: &[String],
ctx: PathspecMatchContext,
) -> bool {
if specs.is_empty() {
return true;
}
let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
let positive = if positive_specs.is_empty() {
true
} else {
positive_specs
.iter()
.any(|s| matches_pathspec_element_with_context(s, path, ctx))
};
if !positive {
return false;
}
if !has_exclude {
return true;
}
let excluded = specs.iter().any(|s| {
pathspec_is_exclude(s) && pathspec_exclude_element_matches_with_context(s, path, ctx)
});
!excluded
}
#[must_use]
pub fn matches_pathspec_list_for_object(
path: &str,
mode: u32,
attr_rules: &[AttrRule],
specs: &[String],
) -> bool {
if specs.is_empty() {
return true;
}
let has_exclude = specs.iter().any(|s| pathspec_is_exclude(s));
let positive_specs: Vec<&String> = specs.iter().filter(|s| !pathspec_is_exclude(s)).collect();
let positive = if positive_specs.is_empty() {
true
} else {
positive_specs
.iter()
.any(|s| matches_pathspec_for_object(s, path, mode, attr_rules))
};
if !positive {
return false;
}
if !has_exclude {
return true;
}
let excluded = specs.iter().any(|s| {
pathspec_is_exclude(s) && matches_pathspec_exclude_for_object(s, path, mode, attr_rules)
});
!excluded
}
fn matches_pathspec_exclude_for_object(
spec: &str,
path: &str,
mode: u32,
attr_rules: &[AttrRule],
) -> bool {
let (elem_magic, raw_pattern) = parse_element_magic(spec);
let mut magic = combine_magic(elem_magic);
if !magic.exclude {
return false;
}
magic.exclude = false;
if magic.literal && magic.glob {
return false;
}
let ctx = context_from_mode_bits(mode);
let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
if let Some(ref attr) = magic.attr_name {
if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
return false;
}
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
if magic.literal || magic.glob || magic.icase {
pathspec_matches_tail(pattern, path_for_match, magic)
} else {
matches_pathspec_with_context(pattern, path_for_match, ctx)
}
}
fn pathspec_matches_tail(pattern: &str, path: &str, magic: PathspecMagic) -> bool {
if pattern.is_empty() {
return true;
}
let flags = if magic.icase { WM_CASEFOLD } else { 0 };
if magic.literal {
return literal_prefix_match(pattern, path);
}
let wm_flags = if magic.glob {
flags | WM_PATHNAME
} else {
flags
};
let pattern_bytes = pattern.as_bytes();
let path_bytes = path.as_bytes();
let simple = simple_length(pattern);
if ps_str_eq(pattern, path, magic.icase) {
return true;
}
if simple == pattern.len() {
if let Some(prefix) = pattern.strip_suffix('/') {
if ps_str_eq(prefix, path, magic.icase) {
return true;
}
let prefix_slash = format!("{prefix}/");
if path_starts_with(path, &prefix_slash, magic.icase) {
return true;
}
} else {
let prefix_slash = format!("{pattern}/");
if path_starts_with(path, &prefix_slash, magic.icase) {
return true;
}
}
}
if magic.glob && !path.contains('/') && pattern.starts_with("**/") {
if wildmatch(pattern_bytes, path_bytes, wm_flags) {
return true;
}
if let Some(suffix) = pattern.strip_prefix("**/") {
if wildmatch(suffix.as_bytes(), path_bytes, wm_flags) {
return true;
}
}
}
if simple < pattern.len() {
if path_bytes.len() < simple {
return false;
}
let path_lit = &path_bytes[..simple];
let pat_lit = &pattern_bytes[..simple];
let same = if magic.icase {
path_lit.eq_ignore_ascii_case(pat_lit)
} else {
path_lit == pat_lit
};
if !same {
return false;
}
let pat_rest = &pattern[simple..];
let path_rest = &path[simple..];
return wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), wm_flags);
}
ps_str_eq(pattern, path, magic.icase)
|| path_starts_with(path, &format!("{pattern}/"), magic.icase)
}
fn ps_str_eq(a: &str, b: &str, icase: bool) -> bool {
if icase {
a.eq_ignore_ascii_case(b)
} else {
a == b
}
}
fn path_starts_with(path: &str, prefix: &str, icase: bool) -> bool {
if icase {
path.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
} else {
path.starts_with(prefix)
}
}
fn literal_prefix_match(pattern: &str, path: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('/') {
return path == prefix || path.starts_with(&format!("{prefix}/"));
}
path == pattern || path.starts_with(&format!("{pattern}/"))
}
fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
if let Some(prefix) = pattern.strip_suffix('/') {
if path.starts_with(&format!("{prefix}/")) {
return true;
}
if path == prefix {
return ctx.is_directory || ctx.is_git_submodule;
}
return false;
}
path == pattern || path.starts_with(&format!("{pattern}/"))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PathspecMatchContext {
pub is_directory: bool,
pub is_git_submodule: bool,
}
#[must_use]
pub fn matches_pathspec(spec: &str, path: &str) -> bool {
matches_pathspec_with_context(spec, path, PathspecMatchContext::default())
}
#[must_use]
pub fn matches_pathspec_with_context(spec: &str, path: &str, ctx: PathspecMatchContext) -> bool {
let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
precompose_utf8_path(spec)
} else {
Cow::Borrowed(spec)
};
let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
precompose_utf8_path(path)
} else {
Cow::Borrowed(path)
};
let spec = spec_nfc.as_ref();
let path = path_nfc.as_ref();
let trimmed = spec.strip_prefix("./").unwrap_or(spec);
if trimmed == "." || trimmed.is_empty() {
return true;
}
let (elem_magic, raw_pattern) = parse_element_magic(trimmed);
let magic = combine_magic(elem_magic);
if magic.literal && magic.glob {
return false;
}
if magic.exclude {
return false;
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
if magic.literal {
if let Some(prefix) = pattern.strip_suffix('/') {
if path_for_match.starts_with(&format!("{prefix}/")) {
return true;
}
if path_for_match == prefix {
return ctx.is_directory || ctx.is_git_submodule;
}
return false;
}
return path_for_match == pattern || path_for_match.starts_with(&format!("{pattern}/"));
}
if let Some(prefix) = pattern.strip_suffix('/') {
if simple_length(pattern) == pattern.len() {
if path_for_match.starts_with(&format!("{prefix}/")) {
return true;
}
if path_for_match == prefix {
return ctx.is_directory || ctx.is_git_submodule;
}
return false;
}
}
if pathspec_matches_tail(pattern, path_for_match, magic) {
return true;
}
if (ctx.is_directory || ctx.is_git_submodule)
&& !path_for_match.is_empty()
&& pattern.len() > path_for_match.len()
&& pattern.as_bytes().get(path_for_match.len()) == Some(&b'/')
&& pattern.starts_with(path_for_match)
&& simple_length(pattern) < pattern.len()
{
return true;
}
false
}
#[must_use]
pub fn context_from_mode_octal(mode: &str) -> PathspecMatchContext {
let Ok(bits) = u32::from_str_radix(mode, 8) else {
return PathspecMatchContext::default();
};
context_from_mode_bits(bits)
}
#[must_use]
pub fn context_from_mode_bits(mode: u32) -> PathspecMatchContext {
let ty = mode & 0o170000;
PathspecMatchContext {
is_directory: ty == 0o040000,
is_git_submodule: ty == 0o160000,
}
}
#[must_use]
pub fn matches_ls_tree_pathspec(
spec: &str,
path: &str,
mode: u32,
attr_rules: &[AttrRule],
) -> bool {
let (elem_magic, raw_pattern) = parse_element_magic(spec);
let mut magic = combine_magic(elem_magic);
magic.exclude = false;
if magic.literal && magic.glob {
return false;
}
let ctx = context_from_mode_bits(mode);
let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
if let Some(ref attr) = magic.attr_name {
if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
return false;
}
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
if magic.literal || magic.glob || magic.icase {
return pathspec_matches_tail(pattern, path_for_match, magic);
}
let spec_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
precompose_utf8_path(pattern)
} else {
Cow::Borrowed(pattern)
};
let path_nfc: Cow<'_, str> = if pathspec_precompose_enabled() {
precompose_utf8_path(path_for_match)
} else {
Cow::Borrowed(path_for_match)
};
let pattern = spec_nfc.as_ref();
let path = path_nfc.as_ref();
let trimmed = pattern.strip_prefix("./").unwrap_or(pattern);
if trimmed == "." || trimmed.is_empty() {
return true;
}
let uses_star_or_question = trimmed.contains('*') || trimmed.contains('?');
if !uses_star_or_question {
return ls_tree_literal_match(trimmed, path, ctx);
}
let nwl = simple_length(trimmed);
let flags = 0u32;
if nwl == trimmed.len() {
return wildmatch(trimmed.as_bytes(), path.as_bytes(), flags);
}
let lit = trimmed.as_bytes().get(..nwl).unwrap_or_default();
let path_b = path.as_bytes();
if path_b.len() < nwl {
return false;
}
if &path_b[..nwl] != lit {
return false;
}
let pat_rest = &trimmed[nwl..];
let path_rest = &path[nwl..];
wildmatch(pat_rest.as_bytes(), path_rest.as_bytes(), flags)
}
#[must_use]
pub fn matches_pathspec_for_object(
spec: &str,
path: &str,
mode: u32,
attr_rules: &[AttrRule],
) -> bool {
let (elem_magic, raw_pattern) = parse_element_magic(spec);
let mut magic = combine_magic(elem_magic);
magic.exclude = false;
if magic.literal && magic.glob {
return false;
}
let ctx = context_from_mode_bits(mode);
let is_dir_for_attr = path.ends_with('/') || ctx.is_directory || ctx.is_git_submodule;
if let Some(ref attr) = magic.attr_name {
if !path_has_gitattribute(attr_rules, path, is_dir_for_attr, attr) {
return false;
}
}
let pattern = strip_top_magic(raw_pattern);
let path_for_match = if let Some(prefix) = magic.prefix.as_deref() {
if !path.starts_with(prefix) {
return false;
}
&path[prefix.len()..]
} else {
path
};
if magic.literal || magic.glob || magic.icase {
pathspec_matches_tail(pattern, path_for_match, magic)
} else {
matches_pathspec_with_context(pattern, path_for_match, ctx)
}
}
#[must_use]
pub fn wildmatch_flags_icase_glob(icase: bool, glob: bool) -> u32 {
let mut f = if glob { WM_PATHNAME } else { 0 };
if icase {
f |= WM_CASEFOLD;
}
f
}
#[cfg(test)]
mod tree_entry_pathspec_tests {
use super::*;
#[test]
fn t6130_bracket_filename_matches_pathspec() {
assert!(matches_pathspec("f[o][o]", "f[o][o]"));
assert!(matches_pathspec(":(glob)f[o][o]", "f[o][o]"));
}
#[test]
fn literal_prefix_and_exact() {
assert!(matches_pathspec("path1", "path1/file1"));
assert!(matches_pathspec_with_context(
"path1/",
"path1/file1",
PathspecMatchContext::default()
));
assert!(matches_pathspec("file0", "file0"));
assert!(!matches_pathspec("path", "path1/file1"));
}
#[test]
fn ls_tree_bracket_in_name_is_literal_prefix() {
assert!(matches_ls_tree_pathspec(
"a[a]",
"a[a]/three",
0o100644,
&[]
));
assert!(!matches_pathspec_with_context(
"a[a]",
"a[a]/three",
PathspecMatchContext::default()
));
}
#[test]
fn wildcards_cross_slash_by_default() {
assert!(matches_pathspec("f*", "file0"));
assert!(matches_pathspec("*file1", "path1/file1"));
assert!(matches_pathspec_with_context(
"path1/f*",
"path1",
PathspecMatchContext {
is_directory: true,
..Default::default()
}
));
assert!(matches_pathspec("path1/*file1", "path1/file1"));
}
#[test]
fn glob_double_star_txt_at_repo_root() {
assert!(pathspec_matches(":(glob)**/*.txt", "untracked.txt"));
assert!(pathspec_matches(":(glob)**/*.txt", "d/untracked.txt"));
}
#[test]
fn trailing_slash_directory_only() {
assert!(!matches_pathspec_with_context(
"file0/",
"file0",
PathspecMatchContext::default()
));
assert!(matches_pathspec_with_context(
"file0/",
"file0",
PathspecMatchContext {
is_directory: true,
..Default::default()
}
));
assert!(matches_pathspec_with_context(
"submod/",
"submod",
PathspecMatchContext {
is_git_submodule: true,
..Default::default()
}
));
}
#[test]
fn exclude_top_short_magic_subtracts_from_positive() {
let specs = vec!["*".to_string(), ":/!sub2".to_string()];
assert!(matches_pathspec_list("sub/file", &specs));
assert!(!matches_pathspec_list("sub2/file", &specs));
assert!(pathspec_exclude_matches(":/!sub2", "sub2/file"));
}
}
#[cfg(test)]
mod pathspec_list_tests {
use super::*;
#[test]
fn exclude_removes_paths_matching_icase_positive() {
let specs = vec![
":(icase)*.txt".to_string(),
":(exclude)submodule/subsub/*".to_string(),
];
assert!(path_allowed_by_pathspec_list(&specs, "submodule/g.txt"));
assert!(!path_allowed_by_pathspec_list(
&specs,
"submodule/subsub/e.txt"
));
}
}