use std::path::{Component, Path};
#[derive(Clone, Debug, Default)]
pub(super) struct Gitignore {
patterns: Vec<Pattern>,
}
#[derive(Clone, Debug)]
struct Pattern {
ignored: bool,
directory_only: bool,
matches_path: bool,
segments: Vec<Segment>,
}
#[derive(Clone, Debug)]
enum Segment {
DoubleStar,
DoubleStarOneOrMore,
Glob(Vec<u8>),
}
impl Gitignore {
pub(super) fn parse(source: &[u8]) -> Self {
let patterns = source.split(|byte| *byte == b'\n').filter_map(Pattern::parse).collect();
Self { patterns }
}
pub(super) fn matches(&self, relative: &Path, is_dir: bool) -> Option<bool> {
let components: Vec<&[u8]> = relative
.components()
.filter_map(|component| match component {
Component::Normal(value) => Some(value.as_encoded_bytes()),
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => None,
})
.collect();
self.matches_components(&components, is_dir)
}
fn matches_components(&self, components: &[&[u8]], is_dir: bool) -> Option<bool> {
self.patterns
.iter()
.filter(|pattern| pattern.matches(components, is_dir))
.map(|pattern| pattern.ignored)
.next_back()
}
}
impl Pattern {
fn parse(raw: &[u8]) -> Option<Self> {
let mut line = raw.strip_suffix(b"\r").unwrap_or(raw);
line = trim_unescaped_spaces(line);
if line.is_empty() || line.first() == Some(&b'#') {
return None;
}
let (ignored, mut body) =
if line.first() == Some(&b'!') { (false, &line[1..]) } else { (true, line) };
if body.is_empty() {
return None;
}
let directory_only = body.last() == Some(&b'/');
if directory_only {
body = &body[..body.len() - 1];
}
if body.last() == Some(&b'\\') && is_escaped(body, body.len()) {
return None;
}
let anchored = body.first() == Some(&b'/');
if anchored {
body = &body[1..];
}
if body.is_empty() || body.starts_with(b"\\/") {
return None;
}
let matches_path = anchored || body.contains(&b'/');
let mut segments = Vec::new();
for (segment, before_escaped_separator) in split_segments(body)? {
if segment.is_empty() {
return None;
}
let segment =
if matches_path && segment.len() >= 2 && segment.iter().all(|byte| *byte == b'*') {
if before_escaped_separator {
Segment::DoubleStarOneOrMore
} else {
Segment::DoubleStar
}
} else {
Segment::Glob(normalize_glob(segment))
};
if matches!(segment, Segment::DoubleStar)
&& matches!(segments.last(), Some(Segment::DoubleStar))
{
continue;
}
segments.push(segment);
}
if segments.is_empty() {
return None;
}
Some(Self { ignored, directory_only, matches_path, segments })
}
fn matches(&self, path: &[&[u8]], is_dir: bool) -> bool {
if path.is_empty() {
return false;
}
if !self.matches_path {
let Some(Segment::Glob(pattern)) = self.segments.first() else {
return false;
};
return path.last().is_some_and(|component| glob_matches(pattern, component))
&& (!self.directory_only || is_dir);
}
segment_path_matches(&self.segments, path, self.directory_only, is_dir)
}
}
fn split_segments(body: &[u8]) -> Option<Vec<(&[u8], bool)>> {
let mut segments = Vec::new();
let mut start = 0;
let mut position = 0;
while position < body.len() {
match body[position] {
b'/' => {
segments.push((&body[start..position], false));
position += 1;
start = position;
}
b'\\' if body.get(position + 1) == Some(&b'/') => {
segments.push((&body[start..position], true));
position += 2;
start = position;
}
b'\\' => position += 2,
b'[' => position += class_match(&body[position..], 0)?.1,
_ => position += 1,
}
}
segments.push((&body[start..], false));
Some(segments)
}
fn normalize_glob(pattern: &[u8]) -> Vec<u8> {
let mut normalized = Vec::with_capacity(pattern.len());
let mut position = 0;
let mut previous_wildcard = false;
while position < pattern.len() {
if pattern[position] == b'\\' && position + 1 < pattern.len() {
normalized.extend_from_slice(&pattern[position..=position + 1]);
position += 2;
previous_wildcard = false;
} else if pattern[position] == b'[' {
let length = class_match(&pattern[position..], 0).map_or(1, |(_, length)| length);
normalized.extend_from_slice(&pattern[position..position + length]);
position += length;
previous_wildcard = false;
} else {
let byte = pattern[position];
if byte != b'*' || !previous_wildcard {
normalized.push(byte);
}
previous_wildcard = byte == b'*';
position += 1;
}
}
normalized
}
fn segment_path_matches(
pattern: &[Segment],
path: &[&[u8]],
directory_only: bool,
target_is_dir: bool,
) -> bool {
let mut previous = vec![false; path.len() + 1];
previous[0] = true;
for (position, segment) in pattern.iter().enumerate() {
let mut current = vec![false; path.len() + 1];
match segment {
Segment::DoubleStar if position + 1 < pattern.len() => {
current[0] = previous[0];
for path_at in 1..=path.len() {
current[path_at] = previous[path_at] || current[path_at - 1];
}
}
Segment::DoubleStar | Segment::DoubleStarOneOrMore => {
for path_at in 1..=path.len() {
current[path_at] = previous[path_at - 1] || current[path_at - 1];
}
}
Segment::Glob(glob) => {
for path_at in 1..=path.len() {
current[path_at] =
previous[path_at - 1] && glob_matches(glob, path[path_at - 1]);
}
}
}
previous = current;
}
previous[path.len()] && (!directory_only || target_is_dir)
}
fn glob_matches(pattern: &[u8], text: &[u8]) -> bool {
let mut pattern_at = 0usize;
let mut text_at = 0usize;
let mut star_at = None;
let mut star_text_at = 0usize;
while text_at < text.len() {
if pattern.get(pattern_at) == Some(&b'*') {
star_at = Some(pattern_at);
pattern_at += 1;
star_text_at = text_at;
continue;
}
let atom = match pattern.get(pattern_at) {
Some(b'\\') if pattern_at + 1 < pattern.len() => {
Some((text[text_at] == pattern[pattern_at + 1], 2))
}
Some(b'?') => Some((true, 1)),
Some(b'[') => {
let Some(class) = class_match(&pattern[pattern_at..], text[text_at]) else {
return false;
};
Some(class)
}
Some(literal) => Some((text[text_at] == *literal, 1)),
None => None,
};
if let Some((true, consumed)) = atom {
pattern_at += consumed;
text_at += 1;
continue;
}
let Some(star) = star_at else {
return false;
};
star_text_at += 1;
text_at = star_text_at;
pattern_at = star + 1;
}
while pattern.get(pattern_at) == Some(&b'*') {
pattern_at += 1;
}
pattern_at == pattern.len()
}
fn class_match(pattern: &[u8], candidate: u8) -> Option<(bool, usize)> {
debug_assert_eq!(pattern.first(), Some(&b'['));
let mut position = 1;
let negated = matches!(pattern.get(position), Some(b'!' | b'^'));
if negated {
position += 1;
}
let mut matched = false;
let mut range_start: Option<u8> = None;
let mut name_close: Option<usize> = None;
let mut first = true;
loop {
let byte = *pattern.get(position)?;
if byte == b']' && !first {
return Some((matched != negated, position + 1));
}
first = false;
match byte {
b'\\' => {
position += 1;
let escaped = *pattern.get(position)?;
matched |= escaped == candidate;
range_start = Some(escaped);
}
b'-' if range_start.is_some()
&& pattern.get(position + 1).is_some_and(|next| *next != b']') =>
{
position += 1;
let mut end = pattern[position];
if end == b'\\' {
position += 1;
end = *pattern.get(position)?;
}
matched |= range_start.is_some_and(|start| (start..=end).contains(&candidate));
range_start = None;
}
b'[' if pattern.get(position + 1) == Some(&b':') => {
let name_start = position + 2;
let close = match name_close {
Some(close) if close >= name_start => close,
_ => {
name_start
+ pattern.get(name_start..)?.iter().position(|byte| *byte == b']')?
}
};
name_close = Some(close);
if close > name_start && pattern[close - 1] == b':' {
matched |= posix_class(&pattern[name_start..close - 1])?(candidate);
range_start = None;
position = close;
} else {
matched |= candidate == b'[';
range_start = Some(b'[');
}
}
literal => {
matched |= literal == candidate;
range_start = Some(literal);
}
}
position += 1;
}
}
fn posix_class(name: &[u8]) -> Option<fn(u8) -> bool> {
Some(match name {
b"alnum" => |byte: u8| byte.is_ascii_alphanumeric(),
b"alpha" => |byte: u8| byte.is_ascii_alphabetic(),
b"blank" => |byte: u8| matches!(byte, b' ' | b'\t'),
b"cntrl" => |byte: u8| byte.is_ascii_control(),
b"digit" => |byte: u8| byte.is_ascii_digit(),
b"graph" => |byte: u8| byte.is_ascii_graphic(),
b"lower" => |byte: u8| byte.is_ascii_lowercase(),
b"print" => |byte: u8| byte.is_ascii_graphic() || byte == b' ',
b"punct" => |byte: u8| byte.is_ascii_punctuation(),
b"space" => |byte: u8| matches!(byte, b'\t' | b'\n' | b'\r' | b' '),
b"upper" => |byte: u8| byte.is_ascii_uppercase(),
b"xdigit" => |byte: u8| byte.is_ascii_hexdigit(),
_ => return None,
})
}
fn trim_unescaped_spaces(mut line: &[u8]) -> &[u8] {
while line.last() == Some(&b' ') && !is_escaped(line, line.len() - 1) {
line = &line[..line.len() - 1];
}
line
}
fn is_escaped(bytes: &[u8], position: usize) -> bool {
let mut slashes = 0usize;
let mut at = position;
while at > 0 && bytes[at - 1] == b'\\' {
slashes += 1;
at -= 1;
}
slashes % 2 == 1
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy)]
struct ConformanceCase {
source: &'static [u8],
path: &'static str,
is_dir: bool,
ignored: bool,
}
fn verdict(source: &[u8], path: &str, is_dir: bool) -> Option<bool> {
Gitignore::parse(source).matches(Path::new(path), is_dir)
}
#[test]
fn comments_escapes_negation_and_last_match_follow_gitignore_order() {
let source = b"# comment\n*.log\n!important.log\n\\#literal\n\\!literal\n";
assert_eq!(verdict(source, "debug.log", false), Some(true));
assert_eq!(verdict(source, "important.log", false), Some(false));
assert_eq!(verdict(source, "#literal", false), Some(true));
assert_eq!(verdict(source, "!literal", false), Some(true));
assert_eq!(verdict(source, "main.rs", false), None);
}
#[test]
fn rooted_basename_directory_and_double_star_patterns_are_distinct() {
let source = b"/build\n*.tmp\ncache/\nsrc/**/generated?.[ch]\nabc/**\n";
assert_eq!(verdict(source, "build", true), Some(true));
assert_eq!(verdict(source, "nested/build", true), None);
assert_eq!(verdict(source, "nested/file.tmp", false), Some(true));
assert_eq!(verdict(source, "cache", false), None);
assert_eq!(verdict(source, "cache", true), Some(true));
assert_eq!(verdict(source, "cache/deep/file", false), None);
assert_eq!(verdict(source, "src/generated1.c", false), Some(true));
assert_eq!(verdict(source, "src/a/b/generated2.h", false), Some(true));
assert_eq!(verdict(source, "src/a/b/generated22.h", false), None);
assert_eq!(verdict(source, "abc", true), None);
assert_eq!(verdict(source, "abc/child", false), Some(true));
assert_eq!(verdict(source, "abc/deep/child", false), Some(true));
}
#[test]
fn bare_double_star_and_invalid_trailing_escape_follow_git_syntax() {
assert_eq!(verdict(b"**\n", "anything", false), Some(true));
assert_eq!(verdict(b"invalid\\\n", "invalid\\", false), None);
}
#[test]
fn long_wildcard_runs_are_stack_safe_without_changing_escaped_stars() {
const LONG_WILDCARD_RUN_BYTES: usize = 64 * 1024;
let source = vec![b'*'; LONG_WILDCARD_RUN_BYTES];
assert_eq!(Gitignore::parse(&source).matches(Path::new("anything"), false), Some(true));
assert_eq!(verdict(b"\\**\n", "*anything", false), Some(true));
assert_eq!(verdict(b"\\**\n", "anything", false), None);
}
#[test]
fn recorded_git_conformance_cases_cover_negation_and_edge_syntax() {
let cases = [
ConformanceCase {
source: b"*.txt\n!docs/\n",
path: "docs/readme.txt",
is_dir: false,
ignored: true,
},
ConformanceCase {
source: b"*.tmp\n/*\n!/src\n",
path: "src/x.tmp",
is_dir: false,
ignored: true,
},
ConformanceCase { source: b"///\n", path: "anything", is_dir: false, ignored: false },
ConformanceCase { source: b"a/**/\n", path: "a/file", is_dir: false, ignored: false },
ConformanceCase { source: b"[]]\n", path: "]", is_dir: false, ignored: true },
];
for case in cases {
assert_eq!(
verdict(case.source, case.path, case.is_dir).unwrap_or(false),
case.ignored,
"git-derived verdict for {}",
case.path
);
if let Some(git_ignored) = git_verdict(case) {
assert_eq!(git_ignored, case.ignored, "git oracle for {}", case.path);
}
}
}
fn git_verdict(case: ConformanceCase) -> Option<bool> {
let root = tempfile::tempdir().expect("gitignore oracle root");
std::fs::write(root.path().join(".gitignore"), case.source).expect("oracle control");
let path = root.path().join(case.path);
if case.is_dir {
std::fs::create_dir_all(&path).expect("oracle directory");
} else {
std::fs::create_dir_all(path.parent().expect("oracle parent"))
.expect("oracle parent directory");
std::fs::write(&path, b"fixture").expect("oracle file");
}
let init = match std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(root.path())
.status()
{
Ok(status) => status,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => panic!("start git oracle: {error}"),
};
assert!(init.success(), "initialize git oracle");
let status = std::process::Command::new("git")
.args(["check-ignore", "--no-index", "--quiet", "--", case.path])
.current_dir(root.path())
.status()
.expect("run git check-ignore oracle");
match status.code() {
Some(0) => Some(true),
Some(1) => Some(false),
code => panic!("git check-ignore exited unexpectedly: {code:?}"),
}
}
#[test]
fn repeated_double_star_segments_have_bounded_matching_work() {
let mut source = b"**/".repeat(40);
source.extend_from_slice(b"x\n");
let mut path = "a/".repeat(24);
path.push('b');
assert_eq!(Gitignore::parse(&source).matches(Path::new(&path), false), None);
}
#[test]
fn repeated_class_name_openers_have_bounded_matching_work() {
let openers = crate::control::DEFAULT_CONTROL_LINE_LIMIT / 2 - 4;
let mut source = b"*[".to_vec();
source.extend(b"[:".repeat(openers));
source.extend_from_slice(b"a]z\n");
let matcher = Gitignore::parse(&source);
assert_eq!(matcher.matches(Path::new(&"z".repeat(255)), false), None);
assert_eq!(matcher.matches(Path::new("[z"), false), Some(true));
assert_eq!(matcher.matches(Path::new("xaz"), false), Some(true));
assert_eq!(matcher.matches(Path::new("bz"), false), None);
}
struct RecordedCase {
pattern: &'static [u8],
ignored: &'static [&'static [u8]],
kept: &'static [&'static [u8]],
}
#[rustfmt::skip]
const BRACKET_CASES: &[RecordedCase] = &[
RecordedCase { pattern: b"x[[:alpha:]]", ignored: &[b"xa", b"xZ"], kept: &[b"x1", b"x_", b"x[", b"x:", b"x]", b"xa]"] },
RecordedCase { pattern: b"[[:digit:]]", ignored: &[b"5"], kept: &[b"a"] },
RecordedCase { pattern: b"[[:alnum:]]", ignored: &[b"a", b"5"], kept: &[b"-"] },
RecordedCase { pattern: b"[[:upper:]]", ignored: &[b"A"], kept: &[b"a"] },
RecordedCase { pattern: b"[[:lower:]]", ignored: &[b"a"], kept: &[b"A"] },
RecordedCase { pattern: b"[[:space:]]x", ignored: &[b" x", b"\x09x", b"\x0dx", b"\x0ax"], kept: &[b"\x0bx", b"\x0cx", b"ax"] },
RecordedCase { pattern: b"[[:blank:]]x", ignored: &[b" x", b"\x09x"], kept: &[b"\x0ax", b"\x0bx"] },
RecordedCase { pattern: b"[[:punct:]]", ignored: &[b"!", b"~", b"_"], kept: &[b"a"] },
RecordedCase { pattern: b"[[:xdigit:]]", ignored: &[b"f", b"F", b"9"], kept: &[b"g"] },
RecordedCase { pattern: b"[[:cntrl:]]", ignored: &[b"\x01", b"\x7f"], kept: &[b"a", b" "] },
RecordedCase { pattern: b"[[:graph:]]", ignored: &[b"a", b"~"], kept: &[b" "] },
RecordedCase { pattern: b"[[:print:]]x", ignored: &[b" x", b"ax"], kept: &[b"\x7fx"] },
RecordedCase { pattern: b"[![:alpha:]][![:alpha:]]", ignored: &[b"\xc3\xa9", b"12"], kept: &[b"ab", b"1a"] },
RecordedCase { pattern: b"[[:alpha:]][[:alpha:]]", ignored: &[b"ab"], kept: &[b"\xc3\xa9"] },
RecordedCase { pattern: b"[[:bogus:]]", ignored: &[], kept: &[b"b", b"[", b"[[:bogus:]]"] },
RecordedCase { pattern: b"[[:alpha:]0-9]", ignored: &[b"a", b"5"], kept: &[b"-"] },
RecordedCase { pattern: b"x[[:alpha]", ignored: &[b"xa", b"x[", b"x:"], kept: &[b"x]", b"xb"] },
RecordedCase { pattern: b"[[:alpha:]", ignored: &[], kept: &[b"a", b"[", b"[[:alpha:]"] },
RecordedCase { pattern: b"x[!:alpha:]", ignored: &[b"xb"], kept: &[b"xa", b"x:"] },
RecordedCase { pattern: b"[[:alpha:]-z]", ignored: &[b"-", b"b"], kept: &[b"5"] },
RecordedCase { pattern: b"x[[:]]", ignored: &[b"x[]", b"x:]"], kept: &[b"x]"] },
RecordedCase { pattern: b"x[[::]]", ignored: &[], kept: &[b"x:", b"x["] },
RecordedCase { pattern: b"x[[:-z]", ignored: &[b"x[", b"xa", b"x:"], kept: &[b"x9"] },
RecordedCase { pattern: b"x[[:alpha:][:digit:]]", ignored: &[b"xa", b"x5"], kept: &[b"x-"] },
RecordedCase { pattern: b"x[[.a.]]", ignored: &[b"xa]", b"x.]", b"x[]"], kept: &[b"xa"] },
RecordedCase { pattern: b"x[[=a=]]", ignored: &[b"xa]", b"x=]"], kept: &[b"xa"] },
RecordedCase { pattern: b"x[a-**]", ignored: &[b"x*", b"xa"], kept: &[b"xb"] },
RecordedCase { pattern: b"*[[:[:a]z", ignored: &[b"[z", b"a:z"], kept: &[b"bz"] },
RecordedCase { pattern: b"*[[:[:]z", ignored: &[], kept: &[b"[z", b"a:z"] },
RecordedCase { pattern: b"[a\\-z]", ignored: &[b"a", b"-", b"z"], kept: &[b"b", b"\\"] },
RecordedCase { pattern: b"[\\]]", ignored: &[b"]"], kept: &[b"\\", b"\\]"] },
RecordedCase { pattern: b"[\\\\]", ignored: &[b"\\"], kept: &[b"]"] },
RecordedCase { pattern: b"[\\a-c]", ignored: &[b"b"], kept: &[b"\\", b"-"] },
RecordedCase { pattern: b"[a-\\c]", ignored: &[b"b"], kept: &[b"\\", b"-"] },
RecordedCase { pattern: b"[\\!a]", ignored: &[b"!", b"a"], kept: &[b"b"] },
RecordedCase { pattern: b"[x\\", ignored: &[], kept: &[b"x", b"[x\\"] },
RecordedCase { pattern: b"\\[a]", ignored: &[b"[a]"], kept: &[b"a"] },
RecordedCase { pattern: b"[a-\\]]", ignored: &[b"a"], kept: &[b"]", b"b"] },
RecordedCase { pattern: b"[]]", ignored: &[b"]"], kept: &[] },
RecordedCase { pattern: b"[]a]", ignored: &[b"]", b"a"], kept: &[b"b"] },
RecordedCase { pattern: b"[!]]", ignored: &[b"a"], kept: &[b"]"] },
RecordedCase { pattern: b"[^]]", ignored: &[b"a"], kept: &[b"]"] },
RecordedCase { pattern: b"[]-a]", ignored: &[b"^"], kept: &[b"\\", b"b"] },
RecordedCase { pattern: b"[]", ignored: &[], kept: &[b"]", b"[]"] },
RecordedCase { pattern: b"[a-]]", ignored: &[b"a]", b"-]"], kept: &[b"b]"] },
RecordedCase { pattern: b"[!a-c]", ignored: &[b"d"], kept: &[b"a"] },
RecordedCase { pattern: b"[^a-c]", ignored: &[b"d"], kept: &[b"a"] },
RecordedCase { pattern: b"[a!]", ignored: &[b"!"], kept: &[b"b"] },
RecordedCase { pattern: b"[!!]", ignored: &[b"a"], kept: &[b"!"] },
RecordedCase { pattern: b"[a-c]", ignored: &[b"a", b"b", b"c"], kept: &[b"d"] },
RecordedCase { pattern: b"[c-a]", ignored: &[b"c"], kept: &[b"a", b"b"] },
RecordedCase { pattern: b"[a-]", ignored: &[b"-", b"a"], kept: &[b"b"] },
RecordedCase { pattern: b"[-a]", ignored: &[b"-", b"a"], kept: &[] },
RecordedCase { pattern: b"[a-c-e]", ignored: &[b"-", b"e"], kept: &[b"d"] },
RecordedCase { pattern: b"[a-a]", ignored: &[b"a"], kept: &[b"b"] },
RecordedCase { pattern: b"[abc", ignored: &[], kept: &[b"[abc", b"a"] },
RecordedCase { pattern: b"foo[", ignored: &[], kept: &[b"foo[", b"foo"] },
RecordedCase { pattern: b"[!", ignored: &[], kept: &[b"[!", b"a"] },
RecordedCase { pattern: b"*[", ignored: &[], kept: &[b"x[", b"x"] },
RecordedCase { pattern: b"*[0-9]", ignored: &[b"file1"], kept: &[b"file"] },
RecordedCase { pattern: b"a[b/c]", ignored: &[b"ab", b"ac"], kept: &[b"a[b/c]"] },
RecordedCase { pattern: b"[/]", ignored: &[], kept: &[b"a", b"x"] },
RecordedCase { pattern: b"**/[[:digit:]]", ignored: &[b"d/5", b"5"], kept: &[b"d/a"] },
];
#[rustfmt::skip]
const ESCAPED_SLASH_CASES: &[RecordedCase] = &[
RecordedCase { pattern: b"a\\/b", ignored: &[b"a/b"], kept: &[b"a\\/b", b"ab", b"a\\b"] },
RecordedCase { pattern: b"x\\/y", ignored: &[b"x/y"], kept: &[] },
RecordedCase { pattern: b"a\\/b\\/c", ignored: &[b"a/b/c"], kept: &[b"a\\/b\\/c"] },
RecordedCase { pattern: b"a\\\\/b", ignored: &[b"a\\/b"], kept: &[b"a/b"] },
RecordedCase { pattern: b"a[/]\\/b", ignored: &[], kept: &[b"a/b"] },
RecordedCase { pattern: b"\\/foo", ignored: &[], kept: &[b"foo", b"\\/foo", b"x/foo"] },
RecordedCase { pattern: b"/\\/foo", ignored: &[], kept: &[b"foo", b"\\/foo"] },
RecordedCase { pattern: b"x/**\\/y", ignored: &[b"x/q/y", b"x/q/r/y"], kept: &[b"x/y", b"y"] },
RecordedCase { pattern: b"**\\/y", ignored: &[b"q/y", b"q/r/y"], kept: &[b"y"] },
RecordedCase { pattern: b"x\\/**\\/y", ignored: &[b"x/q/y"], kept: &[b"x/y"] },
RecordedCase { pattern: b"x/**/**\\/y", ignored: &[b"x/q/y"], kept: &[b"x/y"] },
RecordedCase { pattern: b"x/**\\/**/y", ignored: &[b"x/q/y", b"x/q/r/y"], kept: &[b"x/y"] },
RecordedCase { pattern: b"a/**\\/**", ignored: &[b"a/x/y"], kept: &[b"a/x"] },
RecordedCase { pattern: b"x\\/**/y", ignored: &[b"x/y", b"x/q/y"], kept: &[] },
RecordedCase { pattern: b"a\\/**", ignored: &[b"a/x", b"a/x/y"], kept: &[b"a"] },
RecordedCase { pattern: b"foo\\/", ignored: &[], kept: &[b"foo", b"foo\\"] },
RecordedCase { pattern: b"foo\\\\/", ignored: &[], kept: &[b"foo", b"foo\\"] },
RecordedCase { pattern: b"\\/", ignored: &[], kept: &[b"x"] },
];
#[rustfmt::skip]
const PATH_SEGMENT_CASES: &[RecordedCase] = &[
RecordedCase { pattern: b"a//b", ignored: &[], kept: &[b"a/b", b"a/q/b"] },
RecordedCase { pattern: b"//foo", ignored: &[], kept: &[b"foo", b"x/foo"] },
RecordedCase { pattern: b"a\\//b", ignored: &[], kept: &[b"a/b", b"a/q/b"] },
RecordedCase { pattern: b"a/**//", ignored: &[], kept: &[b"a/x", b"a/x/y"] },
RecordedCase { pattern: b"a/***/b", ignored: &[b"a/b", b"a/q/b", b"a/q/r/b"], kept: &[b"b", b"q/a/b"] },
RecordedCase { pattern: b"***/x", ignored: &[b"x", b"q/x", b"q/r/x"], kept: &[b"y"] },
RecordedCase { pattern: b"x/***", ignored: &[b"x/a", b"x/a/b"], kept: &[b"x", b"q/x/a"] },
RecordedCase { pattern: b"a/****\\/b", ignored: &[b"a/q/b", b"a/q/r/b"], kept: &[b"a/b"] },
];
fn verdict_bytes(source: &[u8], path: &[u8]) -> bool {
let components: Vec<&[u8]> = path.split(|byte| *byte == b'/').collect();
Gitignore::parse(source).matches_components(&components, false).unwrap_or(false)
}
fn assert_recorded_verdicts(cases: &[RecordedCase]) {
for case in cases {
let mut source = case.pattern.to_vec();
source.push(b'\n');
for (names, expected) in [(case.ignored, true), (case.kept, false)] {
for name in names {
assert_eq!(
verdict_bytes(&source, name),
expected,
"pattern {} against {}",
case.pattern.escape_ascii(),
name.escape_ascii()
);
}
}
}
#[cfg(unix)]
git_recorded_oracle(cases);
}
#[test]
fn bracket_expressions_answer_as_git_check_ignore_does() {
assert_recorded_verdicts(BRACKET_CASES);
}
#[test]
fn escaped_slashes_answer_as_git_check_ignore_does() {
assert_recorded_verdicts(ESCAPED_SLASH_CASES);
}
#[test]
fn path_segment_edges_answer_as_git_check_ignore_does() {
assert_recorded_verdicts(PATH_SEGMENT_CASES);
}
#[cfg(unix)]
fn git_recorded_oracle(cases: &[RecordedCase]) {
use std::io::Write as _;
use std::process::{Command, Stdio};
let git = |root: &Path| {
let mut command = Command::new("git");
command
.current_dir(root)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.args(["-c", "core.ignorecase=false", "-c", "core.excludesFile=/dev/null"]);
command
};
let root = tempfile::tempdir().expect("recorded-verdict oracle root");
match git(root.path()).args(["init", "--quiet"]).status() {
Ok(status) => assert!(status.success(), "initialize recorded-verdict oracle"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
Err(error) => panic!("start git recorded-verdict oracle: {error}"),
}
for case in cases {
let mut source = case.pattern.to_vec();
source.push(b'\n');
std::fs::write(root.path().join(".gitignore"), &source).expect("oracle control");
let mut child = git(root.path())
.args(["check-ignore", "--no-index", "-z", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("run git recorded-verdict oracle");
let mut names = Vec::new();
for name in case.ignored.iter().chain(case.kept) {
names.extend_from_slice(name);
names.push(0);
}
child.stdin.take().expect("oracle stdin").write_all(&names).expect("oracle names");
let output = child.wait_with_output().expect("finish git recorded-verdict oracle");
assert!(
matches!(output.status.code(), Some(0 | 1)),
"git check-ignore failed for {}: {}",
case.pattern.escape_ascii(),
String::from_utf8_lossy(&output.stderr)
);
let mut observed: Vec<&[u8]> =
output.stdout.split(|byte| *byte == 0).filter(|name| !name.is_empty()).collect();
observed.sort_unstable();
let mut recorded = case.ignored.to_vec();
recorded.sort_unstable();
assert_eq!(observed, recorded, "git oracle for {}", case.pattern.escape_ascii());
}
}
}