use std::borrow::Cow;
use std::path::{Path, PathBuf};
use hashbrown::HashSet;
use crate::crlf::path_gitattribute_value;
use crate::crlf::AttrRule;
pub use crate::error::{PathspecFromFileParseError, PathspecResolveError};
use crate::precompose_config::argv_precompose_enabled;
use crate::repo::Repository;
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,
) -> Result<Vec<String>, PathspecFromFileParseError> {
if nul_terminated {
let mut chunks: Vec<&[u8]> = data.split(|b| *b == 0).collect();
if chunks.last().copied() == Some(&[] as &[u8]) {
chunks.pop();
}
let mut out = Vec::with_capacity(chunks.len());
for chunk in chunks {
if chunk.is_empty() {
out.push(String::new());
continue;
}
let entry = std::str::from_utf8(chunk)
.map_err(|_| PathspecFromFileParseError::InvalidUtf8InNulRecord)?;
out.push(entry.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() {
out.push(String::new());
continue;
}
if line.starts_with('"') {
if line.ends_with('"') && line.len() >= 2 {
out.push(unquote_c_style_pathspec_line(line)?);
} else {
return Err(PathspecFromFileParseError::InvalidCStyleQuoting {
line: line.to_string(),
});
}
} else {
out.push(line.to_string());
}
}
Ok(out)
}
fn unquote_c_style_pathspec_line(s: &str) -> Result<String, PathspecFromFileParseError> {
let bytes = s.as_bytes();
if bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') || bytes.len() < 2 {
return Err(PathspecFromFileParseError::InvalidCStyleQuoting {
line: s.to_string(),
});
}
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(PathspecFromFileParseError::InvalidEscapeAtEnd);
}
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(PathspecFromFileParseError::TruncatedOctalEscape);
}
let oct = std::str::from_utf8(&inner[i..i + 3])
.map_err(|_| PathspecFromFileParseError::InvalidOctalBytes)?;
out.push(
u8::from_str_radix(oct, 8)
.map_err(|_| PathspecFromFileParseError::InvalidOctalValue)?,
);
i += 2;
}
other => {
return Err(PathspecFromFileParseError::InvalidEscapeSequence {
escape: char::from(other),
});
}
}
i += 1;
}
String::from_utf8(out).map_err(|_| PathspecFromFileParseError::InvalidUtf8InQuotedLine)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathspecInput {
pub data: Vec<u8>,
pub nul_terminated: bool,
}
impl PathspecInput {
#[must_use]
pub fn new(data: Vec<u8>, nul_terminated: bool) -> Self {
Self {
data,
nul_terminated,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedPathspecs {
pub specs: Vec<String>,
pub sources: Vec<String>,
pub scoped: bool,
}
impl ResolvedPathspecs {
pub fn resolve(
repo: &Repository,
cwd: &Path,
raw: &[String],
from_file: Option<PathspecInput>,
) -> Result<Self, PathspecResolveError> {
let work_tree = repo
.work_tree()
.ok_or(PathspecResolveError::NeedsWorkTree)?;
let prefix = pathdiff(cwd, work_tree);
let scoped = from_file.is_some() || !raw.is_empty();
let mut sources = Vec::new();
if let Some(input) = from_file {
let parsed = parse_pathspecs_from_source(&input.data, input.nul_terminated)?;
sources.extend(parsed);
}
for sel in raw {
sources.push(sel.clone());
}
if scoped && sources.is_empty() {
return Err(PathspecResolveError::EmptyPathspec);
}
for sel in &sources {
if sel.is_empty() {
return Err(PathspecResolveError::EmptyPathspec);
}
}
let mut specs = Vec::with_capacity(sources.len());
for sel in &sources {
let resolved = resolve_pathspec_in_worktree_with_git_dir(
sel,
sel,
work_tree,
prefix.as_deref(),
Some(repo.git_dir()),
)
.map_err(|outside| PathspecResolveError::OutsideRepository {
elt: outside.elt,
path: outside.path,
work_tree: outside.work_tree.display().to_string(),
})?;
specs.push(resolved);
}
Ok(Self {
specs,
sources,
scoped,
})
}
#[must_use]
pub fn is_entire_tree(&self) -> bool {
!self.scoped
}
#[must_use]
pub fn into_pathspec_lists(self) -> (Vec<String>, Vec<String>) {
if self.scoped {
(self.specs, self.sources)
} else {
(Vec::new(), Vec::new())
}
}
#[must_use]
pub fn into_specs(self) -> Vec<String> {
if self.scoped {
self.specs
} else {
Vec::new()
}
}
}
#[derive(Debug, Clone, Default)]
struct PathspecMagic {
literal: bool,
glob: bool,
icase: bool,
exclude: bool,
top: bool,
prefix: Option<String>,
attr_requirements: Vec<AttrRequirement>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AttrRequirement {
Set(String),
Unset(String),
Unspecified(String),
Value(String, String),
}
impl AttrRequirement {
fn name(&self) -> &str {
match self {
AttrRequirement::Set(name)
| AttrRequirement::Unset(name)
| AttrRequirement::Unspecified(name)
| AttrRequirement::Value(name, _) => name,
}
}
}
fn git_env_bool(_key: &str, default: bool) -> bool {
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 is_valid_attr_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
}
fn split_attr_expr(expr: &str) -> Result<Vec<String>, String> {
let mut parts = Vec::new();
let mut cur = String::new();
let mut in_value = false;
let mut escaped = false;
for ch in expr.chars() {
if escaped {
if ch.is_ascii_whitespace() {
return Err(
"Escape character '\\' not allowed as last character in attr value".to_string(),
);
}
if ch != ',' {
return Err("Escape character '\\' not allowed for value matching".to_string());
}
cur.push(ch);
escaped = false;
continue;
}
if in_value && ch == '\\' {
escaped = true;
continue;
}
if ch == '=' {
in_value = true;
cur.push(ch);
continue;
}
if ch.is_ascii_whitespace() {
if !cur.is_empty() {
parts.push(cur);
cur = String::new();
}
in_value = false;
continue;
}
cur.push(ch);
}
if escaped {
return Err(
"Escape character '\\' not allowed as last character in attr value".to_string(),
);
}
if !cur.is_empty() {
parts.push(cur);
}
Ok(parts)
}
fn parse_attr_requirements(expr: &str) -> Result<Vec<AttrRequirement>, String> {
if expr.trim().is_empty() {
return Err("empty attr magic is invalid".to_string());
}
let mut out = Vec::new();
for token in split_attr_expr(expr)? {
if let Some(name) = token.strip_prefix('-') {
if name.contains('=') {
return Err("invalid attribute name".to_string());
}
if !is_valid_attr_name(name) {
return Err(format!("{name} is not a valid attribute name"));
}
out.push(AttrRequirement::Unset(name.to_string()));
} else if let Some(name) = token.strip_prefix('!') {
if name.contains('=') {
return Err("invalid attribute name".to_string());
}
if !is_valid_attr_name(name) {
return Err(format!("{name} is not a valid attribute name"));
}
out.push(AttrRequirement::Unspecified(name.to_string()));
} else if let Some((name, value)) = token.split_once('=') {
if !is_valid_attr_name(name) {
return Err(format!("{name} is not a valid attribute name"));
}
if value.is_empty() {
return Err("empty attribute value is not allowed".to_string());
}
out.push(AttrRequirement::Value(name.to_string(), value.to_string()));
} else {
if !is_valid_attr_name(&token) {
return Err(format!("{token} is not a valid attribute name"));
}
out.push(AttrRequirement::Set(token));
}
}
if out.is_empty() {
return Err("empty attr magic is invalid".to_string());
}
Ok(out)
}
pub fn validate_attr_pathspecs(specs: &[String]) -> Result<(), String> {
for spec in specs {
if literal_global() || !spec.starts_with(":(") {
continue;
}
let Some(rest) = spec.strip_prefix(":(") else {
continue;
};
let Some(close) = rest.find(')') else {
continue;
};
let magic_part = &rest[..close];
let mut attr_count = 0usize;
for token in split_long_magic_tokens(magic_part) {
let Some(expr) = token.trim().strip_prefix("attr:") else {
continue;
};
attr_count += 1;
if attr_count > 1 {
return Err("Only one 'attr:' specification is allowed.".to_string());
}
parse_attr_requirements(expr)?;
}
}
Ok(())
}
fn split_long_magic_tokens(magic_part: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
let mut escaped = false;
for ch in magic_part.chars() {
if escaped {
cur.push('\\');
cur.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == ',' {
tokens.push(cur.trim().to_string());
cur.clear();
continue;
}
cur.push(ch);
}
if escaped {
cur.push('\\');
}
tokens.push(cur.trim().to_string());
tokens
}
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 split_long_magic_tokens(magic_part) {
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(expr) = token.strip_prefix("attr:") {
if let Ok(reqs) = parse_attr_requirements(expr) {
magic.attr_requirements = reqs;
}
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_for_rev_tree(path: &str, specs: &[String]) -> bool {
if specs.is_empty() {
return true;
}
for spec in specs {
if !pathspec_is_exclude(spec) && pathspec_wants_descent_into_tree(spec, path) {
return true;
}
}
if !specs.iter().any(|s| pathspec_is_exclude(s)) {
return false;
}
let ctx = PathspecMatchContext {
is_directory: true,
is_git_submodule: false,
precompose_paths: false,
};
for spec in specs {
if pathspec_is_exclude(spec)
&& pathspec_exclude_element_matches_with_context(spec, path, ctx)
{
return false;
}
}
!specs.iter().any(|s| !pathspec_is_exclude(s))
}
#[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 attr_requirements_match(
requirements: &[AttrRequirement],
attr_rules: &[AttrRule],
path: &str,
is_dir: bool,
mode: u32,
) -> bool {
requirements.iter().all(|req| {
let value = if req.name() == "builtin_objectmode" {
if mode == 0 {
None
} else {
Some(format!("{mode:06o}"))
}
} else {
path_gitattribute_value(attr_rules, path, is_dir, req.name())
};
match req {
AttrRequirement::Set(_) => value.as_deref() == Some("set"),
AttrRequirement::Unset(_) => value.as_deref() == Some("unset"),
AttrRequirement::Unspecified(_) => value.is_none(),
AttrRequirement::Value(_, expected) => value.as_deref() == Some(expected.as_str()),
}
})
}
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_requirements.is_empty() {
return false;
}
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_requirements.is_empty() {
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 mut has_exclude = false;
let mut has_positive = false;
let mut positive = false;
for spec in specs {
if pathspec_is_exclude(spec) {
has_exclude = true;
} else {
has_positive = true;
positive = positive || matches_pathspec_element_with_context(spec, path, ctx);
}
}
if has_positive && !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
}
pub(crate) fn sorted_paths_match_pathspec<S: AsRef<str>>(
spec: &str,
sorted_paths: &[S],
ctx: PathspecMatchContext,
) -> bool {
if !is_plain_literal_pathspec(spec, ctx) {
let specs = [spec.to_owned()];
return sorted_paths
.iter()
.any(|p| matches_pathspec_list_with_context(p.as_ref(), &specs, ctx));
}
let has_path_with_prefix = |prefix: &str| {
let at = sorted_paths.partition_point(|p| p.as_ref() < prefix);
sorted_paths
.get(at)
.is_some_and(|p| p.as_ref().starts_with(prefix))
};
if spec.ends_with('/') {
return has_path_with_prefix(spec);
}
let at = sorted_paths.partition_point(|p| p.as_ref() < spec);
if sorted_paths.get(at).is_some_and(|p| p.as_ref() == spec) {
return true;
}
has_path_with_prefix(&format!("{spec}/"))
}
fn is_plain_literal_pathspec(spec: &str, ctx: PathspecMatchContext) -> bool {
!ctx.precompose_paths
&& !ctx.is_directory
&& !ctx.is_git_submodule
&& !spec.is_empty()
&& spec != "."
&& !spec.starts_with("./")
&& !spec.starts_with(':')
&& simple_length(spec) == spec.len()
&& !(literal_global() || glob_global() || noglob_global() || icase_global())
}
pub(crate) struct PathspecListMatcher<'a> {
specs: &'a [String],
ctx: PathspecMatchContext,
literals: Option<LiteralPathspecSet<'a>>,
}
struct LiteralPathspecSet<'a> {
exact: HashSet<&'a str>,
dirs: HashSet<&'a str>,
}
impl<'a> PathspecListMatcher<'a> {
pub(crate) fn new(specs: &'a [String], ctx: PathspecMatchContext) -> Self {
let all_plain =
!specs.is_empty() && specs.iter().all(|s| is_plain_literal_pathspec(s, ctx));
let literals = all_plain.then(|| {
let mut set = LiteralPathspecSet {
exact: HashSet::with_capacity(specs.len()),
dirs: HashSet::with_capacity(specs.len()),
};
for spec in specs {
match spec.strip_suffix('/') {
Some(dir) => {
set.dirs.insert(dir);
}
None => {
set.exact.insert(spec.as_str());
set.dirs.insert(spec.as_str());
}
}
}
set
});
Self {
specs,
ctx,
literals,
}
}
pub(crate) fn matches(&self, path: &str) -> bool {
let Some(set) = &self.literals else {
return matches_pathspec_list_with_context(path, self.specs, self.ctx);
};
if set.exact.contains(path) {
return true;
}
path.bytes()
.enumerate()
.any(|(i, b)| b == b'/' && set.dirs.contains(&path[..i]))
}
}
#[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 !magic.attr_requirements.is_empty()
&& !attr_requirements_match(
&magic.attr_requirements,
attr_rules,
path,
is_dir_for_attr,
mode,
)
{
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;
}
if path_is_under_dir(path, prefix, magic.icase) {
return true;
}
} else if path_is_under_dir(path, pattern, 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_is_under_dir(path, 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 path_is_under_dir(path: &str, dir: &str, icase: bool) -> bool {
path.as_bytes().get(dir.len()) == Some(&b'/') && path_starts_with(path, dir, icase)
}
fn literal_prefix_match(pattern: &str, path: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('/') {
return path == prefix || path_is_under_dir(path, prefix, false);
}
path == pattern || path_is_under_dir(path, pattern, false)
}
fn ls_tree_literal_match(pattern: &str, path: &str, ctx: PathspecMatchContext) -> bool {
if let Some(prefix) = pattern.strip_suffix('/') {
if path_is_under_dir(path, prefix, false) {
return true;
}
if path == prefix {
return ctx.is_directory || ctx.is_git_submodule;
}
return false;
}
path == pattern || path_is_under_dir(path, pattern, false)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PathspecMatchContext {
pub is_directory: bool,
pub is_git_submodule: bool,
pub precompose_paths: 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 ctx.precompose_paths {
precompose_utf8_path(spec)
} else {
Cow::Borrowed(spec)
};
let path_nfc: Cow<'_, str> = if ctx.precompose_paths {
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_is_under_dir(path_for_match, prefix, false) {
return true;
}
if path_for_match == prefix {
return ctx.is_directory || ctx.is_git_submodule;
}
return false;
}
return path_for_match == pattern || path_is_under_dir(path_for_match, pattern, false);
}
if let Some(prefix) = pattern.strip_suffix('/') {
if simple_length(pattern) == pattern.len() {
if path_is_under_dir(path_for_match, prefix, false) {
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,
precompose_paths: false,
}
}
#[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 !magic.attr_requirements.is_empty()
&& !attr_requirements_match(
&magic.attr_requirements,
attr_rules,
path,
is_dir_for_attr,
mode,
)
{
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> = Cow::Borrowed(pattern);
let path_nfc: Cow<'_, str> = 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 !magic.attr_requirements.is_empty()
&& !attr_requirements_match(
&magic.attr_requirements,
attr_rules,
path,
is_dir_for_attr,
mode,
)
{
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
}
#[derive(Debug, Clone)]
pub struct PathOutsideRepository {
pub elt: String,
pub path: String,
pub work_tree: PathBuf,
}
impl std::fmt::Display for PathOutsideRepository {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: '{}' is outside repository at '{}'",
self.elt,
self.path,
self.work_tree.display()
)
}
}
pub fn resolve_magic_pathspec(spec: &str, cwd_prefix: &str) -> Option<String> {
if !spec.starts_with(":(") {
return None;
}
let close_idx = spec.find(')')?;
let magic_prefix = &spec[..=close_idx];
let tail = &spec[close_idx + 1..];
Some(resolve_magic_pathspec_parts(magic_prefix, tail, cwd_prefix))
}
fn resolve_magic_pathspec_parts(magic_prefix: &str, tail: &str, cwd_prefix: &str) -> String {
if has_magic_prefix_token(magic_prefix) {
return format!("{magic_prefix}{tail}");
}
if let Some(rooted_tail) = tail.strip_prefix('/') {
return format!("{magic_prefix}{}", normalize_relative_path_str(rooted_tail));
}
let combined = if cwd_prefix.is_empty() {
normalize_relative_path_str(tail)
} else {
normalize_relative_path_str(&format!("{cwd_prefix}{tail}"))
};
let cwd_base = normalize_relative_path_str(cwd_prefix.trim_end_matches('/'));
if !cwd_base.is_empty()
&& (combined == cwd_base || combined.starts_with(&format!("{cwd_base}/")))
{
let after_base = combined
.strip_prefix(&cwd_base)
.unwrap_or(combined.as_str());
let remainder = after_base.strip_prefix('/').unwrap_or(after_base);
let magic_with_prefix = inject_magic_prefix_token(magic_prefix, &format!("{cwd_base}/"));
return format!("{magic_with_prefix}{remainder}");
}
format!("{magic_prefix}{combined}")
}
fn has_magic_prefix_token(magic_prefix: &str) -> bool {
let Some(inner) = magic_prefix
.strip_prefix(":(")
.and_then(|s| s.strip_suffix(')'))
else {
return false;
};
inner
.split(',')
.map(str::trim)
.any(|token| token.starts_with("prefix:"))
}
fn inject_magic_prefix_token(magic_prefix: &str, prefix: &str) -> String {
let Some(inner) = magic_prefix
.strip_prefix(":(")
.and_then(|s| s.strip_suffix(')'))
else {
return magic_prefix.to_string();
};
if inner.trim().is_empty() {
format!(":(prefix:{prefix})")
} else {
format!(":({inner},prefix:{prefix})")
}
}
fn normalize_repo_root_relative_pathspec(pathspec: &str) -> String {
let mut spec = pathspec;
let mut stripped = false;
while let Some(rest) = spec.strip_prefix("./") {
stripped = true;
spec = rest.trim_start_matches('/');
}
if !stripped {
return pathspec.to_owned();
}
if spec.is_empty() {
return ".".to_owned();
}
if spec.starts_with(':') && !literal_pathspecs_enabled() {
return format!(":(literal){spec}");
}
spec.to_owned()
}
fn normalize_pathspec_separators(pathspec: &str) -> std::borrow::Cow<'_, str> {
#[cfg(windows)]
{
if !pathspec.starts_with(':') && pathspec.contains('\\') {
return std::borrow::Cow::Owned(pathspec.replace('\\', "/"));
}
}
std::borrow::Cow::Borrowed(pathspec)
}
fn normalize_relative_path_str(path: &str) -> String {
let mut parts: Vec<String> = Vec::new();
for component in std::path::Path::new(path).components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
parts.pop();
}
std::path::Component::Normal(seg) => {
parts.push(seg.to_string_lossy().to_string());
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
}
}
parts.join("/")
}
#[must_use]
pub fn pathdiff(cwd: &Path, work_tree: &Path) -> Option<String> {
use crate::git_path::{path_for_disk_compare, path_to_git_slash};
let cwd_cmp = path_for_disk_compare(cwd);
let wt_cmp = path_for_disk_compare(work_tree);
if cwd_cmp == wt_cmp {
return None;
}
let rel = cwd_cmp.strip_prefix(&wt_cmp).ok()?;
let git = path_to_git_slash(rel);
if git.is_empty() {
None
} else {
Some(git)
}
}
fn prepend_cwd_to_short_exclude_pathspec(spec: &str, cwd: &str) -> Option<String> {
let cwd = cwd.trim_end_matches('/');
if cwd.is_empty() {
return None;
}
let bytes = spec.as_bytes();
if bytes.first().copied() != Some(b':') {
return None;
}
if bytes.get(1).copied() == Some(b'/') {
return None;
}
let mut i = 1usize;
while i < bytes.len() && bytes[i] != b':' {
let ch = bytes[i];
if ch == b'^' {
i += 1;
continue;
}
let is_magic = matches!(ch, b'!' | b'/');
if is_magic {
i += 1;
continue;
}
break;
}
if i < bytes.len() && bytes[i] == b':' {
i += 1;
}
let pattern = spec.get(i..)?;
if pattern.is_empty() || pattern.starts_with('/') {
return None;
}
Some(format!("{}{}/{pattern}", &spec[..i], cwd))
}
#[must_use]
pub fn pathspec_match_context_with_precompose(precompose_paths: bool) -> PathspecMatchContext {
PathspecMatchContext {
precompose_paths,
..PathspecMatchContext::default()
}
}
#[must_use]
pub fn matches_pathspec_list_precompose(
path: &str,
specs: &[String],
precompose_paths: bool,
) -> bool {
matches_pathspec_list_with_context(
path,
specs,
pathspec_match_context_with_precompose(precompose_paths),
)
}
#[must_use]
pub fn argv_pathspec_preserves_spelling(pathspec: &str) -> bool {
pathspec.starts_with(":(")
|| pathspec.starts_with(":/")
|| pathspec.starts_with(":!")
|| pathspec.starts_with(":^")
}
#[must_use]
pub fn precompose_argv_pathspec(pathspec: &str) -> Cow<'_, str> {
if argv_pathspec_preserves_spelling(pathspec) {
Cow::Borrowed(pathspec)
} else {
precompose_utf8_path(pathspec)
}
}
#[must_use]
pub fn resolve_pathspec(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, false)
}
#[must_use]
pub fn resolve_pathspec_with_argv_precompose(
pathspec: &str,
work_tree: &Path,
prefix: Option<&str>,
argv_precompose: bool,
) -> String {
let pathspec = if argv_precompose {
precompose_argv_pathspec(pathspec)
} else {
Cow::Borrowed(pathspec)
};
resolve_pathspec_impl(pathspec.as_ref(), work_tree, prefix)
}
fn resolve_pathspec_impl(pathspec: &str, work_tree: &Path, prefix: Option<&str>) -> String {
let pathspec = normalize_pathspec_separators(pathspec);
let pathspec = pathspec.as_ref();
if pathspec == "." {
return match prefix {
Some(p) if !p.is_empty() => p.to_owned(),
_ => ".".to_owned(),
};
}
if pathspec.contains("../") || pathspec.starts_with("../") {
let cwd = match prefix.filter(|p| !p.is_empty()) {
Some(p) => work_tree.join(p),
None => work_tree.to_path_buf(),
};
let abs = cwd.join(pathspec);
let mut parts: Vec<std::ffi::OsString> = Vec::new();
for component in abs.components() {
use std::path::Component;
match component {
Component::ParentDir => {
parts.pop();
}
Component::CurDir => {}
other => parts.push(other.as_os_str().to_os_string()),
}
}
let abs_norm: PathBuf = parts.iter().collect();
if let Some(rel) = crate::git_path::strip_worktree_prefix(&abs_norm, work_tree) {
return rel;
}
}
if Path::new(pathspec).is_absolute() {
let abs = Path::new(pathspec);
if let Some(rel) = crate::git_path::strip_worktree_prefix(abs, work_tree) {
return rel;
}
return pathspec.to_owned();
}
if pathspec.starts_with(':') {
if let Some(p) = prefix {
if !p.is_empty() && !literal_pathspecs_enabled() {
let cwd_ps = format!("{}/", p.trim_end_matches('/'));
if pathspec.starts_with(":(") {
if let Some(resolved) = resolve_magic_pathspec(pathspec, &cwd_ps) {
return resolved;
}
return pathspec.to_owned();
}
if pathspec_is_exclude(pathspec) {
if let Some(fixed) = prepend_cwd_to_short_exclude_pathspec(pathspec, p) {
return fixed;
}
}
}
}
if let Some(rest) = pathspec.strip_prefix(":/") {
if rest.starts_with('!') || rest.starts_with('^') {
return pathspec.to_owned();
}
return rest.to_owned();
}
if pathspec.starts_with(":(") {
return pathspec.to_owned();
}
return pathspec.to_owned();
}
match prefix {
Some(p) if !p.is_empty() => {
normalize_relative_path_str(&PathBuf::from(p).join(pathspec).to_string_lossy())
}
_ => normalize_repo_root_relative_pathspec(pathspec),
}
}
pub fn resolve_pathspec_in_worktree(
elt: &str,
pathspec: &str,
work_tree: &Path,
prefix: Option<&str>,
) -> Result<String, PathOutsideRepository> {
resolve_pathspec_in_worktree_with_argv_precompose(elt, pathspec, work_tree, prefix, false)
}
pub fn resolve_pathspec_in_worktree_with_git_dir(
elt: &str,
pathspec: &str,
work_tree: &Path,
prefix: Option<&str>,
git_dir: Option<&Path>,
) -> Result<String, PathOutsideRepository> {
let argv_precompose = argv_precompose_enabled(git_dir);
resolve_pathspec_in_worktree_with_argv_precompose(
elt,
pathspec,
work_tree,
prefix,
argv_precompose,
)
}
fn resolve_pathspec_in_worktree_with_argv_precompose(
elt: &str,
pathspec: &str,
work_tree: &Path,
prefix: Option<&str>,
argv_precompose: bool,
) -> Result<String, PathOutsideRepository> {
let resolved =
resolve_pathspec_with_argv_precompose(pathspec, work_tree, prefix, argv_precompose);
if Path::new(&resolved).is_absolute() {
let wt = work_tree
.canonicalize()
.unwrap_or_else(|_| work_tree.to_path_buf());
return Err(PathOutsideRepository {
elt: elt.to_string(),
path: resolved,
work_tree: wt,
});
}
Ok(resolved)
}
#[must_use]
pub fn normalize_worktree_file_path(
file_path: &str,
work_tree: &Path,
prefix: Option<&str>,
) -> String {
normalize_worktree_file_path_with_git_dir(file_path, work_tree, prefix, None)
}
#[must_use]
pub fn normalize_worktree_file_path_with_git_dir(
file_path: &str,
work_tree: &Path,
prefix: Option<&str>,
git_dir: Option<&Path>,
) -> String {
let argv_precompose = argv_precompose_enabled(git_dir);
let resolved =
resolve_pathspec_with_argv_precompose(file_path, work_tree, prefix, argv_precompose);
if Path::new(&resolved).is_absolute() {
file_path.to_string()
} else {
resolved
}
}
#[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 dir_prefix_needs_slash_boundary() {
assert!(matches_pathspec("a/b", "a/b/c"));
assert!(!matches_pathspec("a/b", "a/bc"));
assert!(!matches_pathspec("a/b", "a"));
assert!(matches_pathspec(":(literal)dir", "dir/x"));
assert!(!matches_pathspec(":(literal)dir", "dirx"));
assert!(matches_pathspec(":(icase)DIR", "dir/x"));
assert!(!matches_pathspec(":(icase)DIR", "dirx"));
assert!(matches_pathspec("dir/", "dir/x"));
assert!(!matches_pathspec("dir/", "dir"));
}
#[test]
fn sorted_lookup_agrees_with_linear_scan() {
let mut paths: Vec<&str> = vec![
"a",
"a-b",
"a.txt",
"a/b",
"a/b/c.txt",
"a/bc",
"a0",
"b/x[1].txt",
"dir/sub/file",
"dir-sub",
"f[o][o]",
"z",
];
paths.sort_unstable();
let specs = [
"a",
"a/",
"a/b",
"a/b/",
"a/bc",
"a-",
"a-b",
"a.txt",
"b",
"b/",
"dir",
"dir/sub",
"dir/sub/",
"dir/su",
"dir-sub",
"f[o][o]",
"*.txt",
"a/*",
":(glob)**/*.txt",
":(literal)f[o][o]",
":(icase)A/B",
":!a",
".",
"./a/b",
"",
"z",
"zz",
"/",
"a//",
];
for ctx in [
PathspecMatchContext::default(),
PathspecMatchContext {
precompose_paths: true,
..PathspecMatchContext::default()
},
PathspecMatchContext {
is_directory: true,
..PathspecMatchContext::default()
},
] {
for spec in specs {
let list = [spec.to_owned()];
let linear = paths
.iter()
.any(|p| matches_pathspec_list_with_context(p, &list, ctx));
assert_eq!(
sorted_paths_match_pathspec(spec, &paths, ctx),
linear,
"spec {spec:?} ctx {ctx:?}"
);
}
}
let empty: [&str; 0] = [];
assert!(!sorted_paths_match_pathspec(
"a",
&empty,
PathspecMatchContext::default()
));
}
#[test]
fn list_matcher_agrees_with_linear_matching() {
let paths = [
"a",
"a-b",
"a.txt",
"a/",
"a/b",
"a/b/c.txt",
"a/bc",
"a//x",
"a0",
"b/x[1].txt",
"dir/sub/file",
"dir-sub",
"f[o][o]",
"z",
"zz/a",
"é/x",
"",
];
let lists: &[&[&str]] = &[
&[],
&["a"],
&["a/"],
&["a//"],
&["a/b", "dir/sub/", "z"],
&["a/b/", "a/bc", "dir", "zz"],
&["dir/su", "dir-sub", "f[o][o]"],
&["é", "a-"],
&["a", "*.txt"],
&["dir", ":!dir/sub"],
&[":!a"],
&["./a", "z"],
&[".", "a"],
&["", "a"],
&[":(icase)A/B", "z"],
];
for ctx in [
PathspecMatchContext::default(),
PathspecMatchContext {
precompose_paths: true,
..PathspecMatchContext::default()
},
PathspecMatchContext {
is_directory: true,
..PathspecMatchContext::default()
},
] {
for list in lists {
let specs: Vec<String> = list.iter().map(|s| (*s).to_owned()).collect();
let matcher = PathspecListMatcher::new(&specs, ctx);
for path in paths {
assert_eq!(
matcher.matches(path),
matches_pathspec_list_with_context(path, &specs, ctx),
"path {path:?} specs {specs:?} ctx {ctx:?}"
);
}
}
}
}
#[test]
fn list_matcher_uses_literal_lookup_only_for_plain_lists() {
let ctx = PathspecMatchContext::default();
let plain = vec!["a/b".to_owned(), "dir/".to_owned()];
assert!(PathspecListMatcher::new(&plain, ctx).literals.is_some());
let glob = vec!["a/b".to_owned(), "*.txt".to_owned()];
assert!(PathspecListMatcher::new(&glob, ctx).literals.is_none());
let exclude = vec!["a/b".to_owned(), ":!a/b/c".to_owned()];
assert!(PathspecListMatcher::new(&exclude, ctx).literals.is_none());
assert!(PathspecListMatcher::new(&[], ctx).literals.is_none());
}
#[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::*;
use crate::crlf::parse_gitattributes_content;
#[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"
));
}
#[test]
fn prefixed_attr_exclude_removes_matching_child_path() {
let specs = vec![
"sub".to_string(),
":(exclude,attr:labelB,prefix:sub/)".to_string(),
];
let exclude_only = vec![":(exclude,attr:labelB,prefix:sub/)".to_string()];
let attrs = parse_gitattributes_content("fileB labelB\n");
assert!(!matches_pathspec_list_for_object(
"sub/fileB",
0o100644,
&attrs,
&specs,
));
assert!(!matches_pathspec_list_for_object(
"sub/fileB",
0o100644,
&attrs,
&exclude_only,
));
}
}
#[cfg(test)]
mod resolve_pathspec_tests {
use super::*;
fn wt() -> PathBuf {
PathBuf::from("/tmp/grit-pathspec-worktree")
}
#[test]
fn strips_leading_dot_slash_at_repo_root() {
let wt = wt();
assert_eq!(resolve_pathspec("./-", &wt, None), "-");
assert_eq!(resolve_pathspec("./a/b", &wt, None), "a/b");
assert_eq!(resolve_pathspec("././a", &wt, None), "a");
assert_eq!(resolve_pathspec(".//a", &wt, None), "a");
assert_eq!(resolve_pathspec("./", &wt, None), ".");
assert_eq!(resolve_pathspec(".", &wt, None), ".");
}
#[test]
fn prefixed_cwd_resolution_unchanged() {
let wt = wt();
assert_eq!(
resolve_pathspec("file", &wt, Some("sub/dir")),
"sub/dir/file"
);
assert_eq!(resolve_pathspec(":(glob)*.rs", &wt, None), ":(glob)*.rs");
}
#[test]
fn dot_slash_colon_filename_stays_literal_not_exclude_magic() {
let wt = wt();
let resolved = resolve_pathspec("./:!foo", &wt, None);
assert_eq!(resolved, ":(literal):!foo");
assert!(!pathspec_is_exclude(&resolved));
assert!(pathspec_matches(&resolved, ":!foo"));
assert!(!pathspec_matches(&resolved, "other"));
}
#[test]
fn resolve_absolute_path_under_worktree() {
let tmp = tempfile::tempdir().expect("tempdir");
let wt = tmp.path().join("repo");
std::fs::create_dir_all(wt.join("d")).expect("mkdir");
let file = wt.join("d/b.txt");
std::fs::write(&file, b"x").expect("write");
let wt = wt.canonicalize().expect("canonicalize");
let abs = file.canonicalize().expect("canonicalize file");
let spec = abs.to_string_lossy();
assert_eq!(
resolve_pathspec(&spec, &wt, None),
"d/b.txt",
"absolute forward-slash pathspec"
);
}
#[cfg(windows)]
mod windows {
use super::*;
#[test]
fn resolve_backslash_relative_and_absolute_pathspecs() {
let tmp = tempfile::tempdir().expect("tempdir");
let wt = tmp.path().join("repo");
std::fs::create_dir_all(wt.join("d")).expect("mkdir");
let file_a = wt.join("d/a.txt");
let file_b = wt.join("d/b.txt");
std::fs::write(&file_a, b"a").expect("write");
std::fs::write(&file_b, b"b").expect("write");
let wt = wt.canonicalize().expect("canonicalize worktree");
assert_eq!(
resolve_pathspec(r"d\a.txt", &wt, None),
"d/a.txt",
"backslash relative pathspec (issue #915 A)"
);
let abs_back = file_b.canonicalize().expect("canonicalize");
let abs_back_spec = abs_back.display().to_string();
assert_eq!(
resolve_pathspec(&abs_back_spec, &wt, None),
"d/b.txt",
"absolute backslash pathspec (issue #915 B)"
);
let abs_forward = abs_back_spec.replace('\\', "/");
assert_eq!(
resolve_pathspec(&abs_forward, &wt, None),
"d/b.txt",
"absolute forward-slash pathspec (issue #915 C)"
);
}
}
#[test]
fn nfd_pathspec_matches_nfc_index_path_when_precompose_enabled() {
let nfc_path = "caf\u{00e9}.txt";
let nfd_spec = format!("cafe\u{0301}.txt");
assert!(!matches_pathspec_list(&nfc_path, &[nfd_spec.clone()]));
assert!(matches_pathspec_list_precompose(
nfc_path,
&[nfd_spec.clone()],
true
));
}
#[test]
fn argv_precompose_normalizes_nfd_literal_pathspec() {
let nfd = format!("cafe\u{0301}.txt");
let nfc = precompose_argv_pathspec(&nfd);
assert_eq!(nfc.as_ref(), "caf\u{00e9}.txt");
assert!(argv_pathspec_preserves_spelling(":(glob)*.rs"));
assert_eq!(
precompose_argv_pathspec(":(glob)*.rs").as_ref(),
":(glob)*.rs"
);
}
}
#[cfg(test)]
mod resolved_pathspecs_tests {
use super::*;
use crate::repo::{InitOptions, Repository};
fn repo_with_tree() -> (tempfile::TempDir, Repository) {
let dir = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
std::fs::write(dir.path().join("top.txt"), b"a").expect("write");
std::fs::create_dir_all(dir.path().join("sub")).expect("mkdir");
std::fs::write(dir.path().join("sub/inner.txt"), b"b").expect("write");
(dir, repo)
}
#[test]
fn resolved_pathspecs_subdir_prefix() {
let (dir, repo) = repo_with_tree();
let sub = dir.path().join("sub");
let resolved =
ResolvedPathspecs::resolve(&repo, &sub, &["inner.txt".to_string()], None).expect("ok");
assert_eq!(resolved.specs, ["sub/inner.txt"]);
assert!(resolved.scoped);
}
#[test]
fn resolved_pathspecs_top_level_magic() {
let (dir, repo) = repo_with_tree();
let resolved =
ResolvedPathspecs::resolve(&repo, dir.path(), &[":/top.txt".to_string()], None)
.expect("ok");
assert_eq!(resolved.specs, ["top.txt"]);
}
#[test]
fn resolved_pathspecs_parent_outside_repo() {
let (dir, repo) = repo_with_tree();
let outside = dir.path().parent().expect("parent").join("outside.txt");
std::fs::write(&outside, b"x").expect("write outside");
let spec = outside.to_string_lossy().into_owned();
let err =
ResolvedPathspecs::resolve(&repo, dir.path(), &[spec], None).expect_err("outside");
assert!(matches!(
err,
PathspecResolveError::OutsideRepository { .. }
));
}
#[test]
fn resolved_pathspecs_empty_argv() {
let (dir, repo) = repo_with_tree();
let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[String::new()], None)
.expect_err("empty");
assert_eq!(err, PathspecResolveError::EmptyPathspec);
}
#[test]
fn resolved_pathspecs_from_file_lf_and_quoted() {
let (dir, repo) = repo_with_tree();
let input = PathspecInput::new(b"top.txt\n\"sub/inner.txt\"\n".to_vec(), false);
let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
assert_eq!(resolved.specs, ["top.txt", "sub/inner.txt"]);
}
#[test]
fn resolved_pathspecs_from_file_nul() {
let (dir, repo) = repo_with_tree();
let input = PathspecInput::new(b"top.txt\0sub/\0".to_vec(), true);
let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
assert_eq!(resolved.specs, ["top.txt", "sub/"]);
}
#[test]
fn resolved_pathspecs_from_file_nul_preserves_literal_bytes() {
let (dir, repo) = repo_with_tree();
std::fs::write(dir.path().join(" spaced "), b"x").expect("write");
let quoted_dir = dir.path().join("\"double\"");
std::fs::create_dir_all("ed_dir).expect("mkdir");
std::fs::write(quoted_dir.join("f"), b"y").expect("write");
let input = PathspecInput::new(b" spaced \0\"double\"\0".to_vec(), true);
let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect("ok");
assert_eq!(resolved.sources, [" spaced ", "\"double\""]);
}
#[test]
fn resolved_pathspecs_from_file_blank_line_errors() {
let (dir, repo) = repo_with_tree();
let input = PathspecInput::new(b"\n".to_vec(), false);
let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
assert_eq!(err, PathspecResolveError::EmptyPathspec);
}
#[test]
fn resolved_pathspecs_from_file_nul_empty_record_errors() {
let (dir, repo) = repo_with_tree();
let input = PathspecInput::new(b"a\0\0b".to_vec(), true);
let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
assert_eq!(err, PathspecResolveError::EmptyPathspec);
}
#[test]
fn resolved_pathspecs_from_file_invalid_quote() {
let (dir, repo) = repo_with_tree();
let input = PathspecInput::new(b"\"bad\\z\"\n".to_vec(), false);
let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
assert!(matches!(
err,
PathspecResolveError::FromFile(
PathspecFromFileParseError::InvalidEscapeSequence { .. }
)
));
let input = PathspecInput::new(b"\"unterminated\n".to_vec(), false);
let err = ResolvedPathspecs::resolve(&repo, dir.path(), &[], Some(input)).expect_err("err");
assert!(matches!(
err,
PathspecResolveError::FromFile(PathspecFromFileParseError::InvalidCStyleQuoting { .. })
));
}
#[test]
fn resolved_pathspecs_entire_tree_when_unscoped() {
let (dir, repo) = repo_with_tree();
let resolved = ResolvedPathspecs::resolve(&repo, dir.path(), &[], None).expect("ok");
assert!(resolved.is_entire_tree());
assert!(resolved.specs.is_empty());
}
}