weavatrix-git 0.3.2

Fast, bounded, evidence-carrying Git reader with an optional read-only MCP server
Documentation
//! Bounded `.gitignore` matching for worktree safety.
//!
//! This is a Git-compatible subset: comments, negation, directory-only
//! patterns, unanchored basename patterns, and `*` / `?` / `**` globs.
//! Nested ignore files apply relative to their directory. Global excludes
//! are intentionally omitted so results stay portable.

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct IgnoreRule {
    negated: bool,
    dir_only: bool,
    anchored: bool,
    pattern: String,
}

#[derive(Clone, Debug, Default)]
pub(crate) struct IgnoreLayer {
    prefix: String,
    rules: Vec<IgnoreRule>,
}

#[derive(Clone, Debug, Default)]
pub(crate) struct IgnoreStack {
    layers: Vec<IgnoreLayer>,
}

impl IgnoreStack {
    pub(crate) fn push_file(&mut self, prefix: &str, text: &str) {
        let rules = parse_rules(text);
        if !rules.is_empty() {
            self.layers.push(IgnoreLayer {
                prefix: prefix.trim_end_matches('/').to_owned(),
                rules,
            });
        }
    }

    pub(crate) fn is_ignored(&self, relative: &str, is_dir: bool) -> bool {
        let mut ignored = false;
        for layer in &self.layers {
            if let Some(local) = strip_prefix(relative, &layer.prefix) {
                for rule in &layer.rules {
                    if rule.dir_only && !is_dir {
                        continue;
                    }
                    if rule_matches(rule, local) {
                        ignored = !rule.negated;
                    }
                }
            }
        }
        ignored
    }
}

fn parse_rules(text: &str) -> Vec<IgnoreRule> {
    text.lines().filter_map(parse_line).collect()
}

fn parse_line(raw: &str) -> Option<IgnoreRule> {
    let line = raw.trim_end_matches('\r');
    if line.is_empty() || line.starts_with('#') {
        return None;
    }
    let mut body = line.to_owned();
    let negated = body.starts_with('!');
    if negated {
        body.remove(0);
    }
    if body.is_empty() {
        return None;
    }
    let dir_only = body.ends_with('/');
    if dir_only {
        body.pop();
    }
    if body.starts_with('/') {
        body.remove(0);
        return Some(IgnoreRule {
            negated,
            dir_only,
            anchored: true,
            pattern: body,
        });
    }
    let anchored = body.contains('/');
    Some(IgnoreRule {
        negated,
        dir_only,
        anchored,
        pattern: body,
    })
}

fn strip_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
    if prefix.is_empty() {
        return Some(path);
    }
    if path == prefix {
        return Some("");
    }
    let head = [prefix, "/"].concat();
    path.strip_prefix(&head)
}

fn rule_matches(rule: &IgnoreRule, local: &str) -> bool {
    if local.is_empty() {
        return false;
    }
    if rule.anchored {
        return glob_match(&rule.pattern, local);
    }
    if glob_match(&rule.pattern, local) {
        return true;
    }
    local
        .rsplit('/')
        .next()
        .is_some_and(|name| glob_match(&rule.pattern, name))
}

fn glob_match(pattern: &str, text: &str) -> bool {
    glob_bytes(pattern.as_bytes(), text.as_bytes())
}

fn glob_bytes(pattern: &[u8], text: &[u8]) -> bool {
    if pattern.is_empty() {
        return text.is_empty();
    }
    if pattern.starts_with(b"**") {
        return glob_starstar(&pattern[2..], text);
    }
    if pattern[0] == b'*' {
        return glob_star(&pattern[1..], text);
    }
    if pattern[0] == b'?' {
        return !text.is_empty() && text[0] != b'/' && glob_bytes(&pattern[1..], &text[1..]);
    }
    !text.is_empty() && pattern[0] == text[0] && glob_bytes(&pattern[1..], &text[1..])
}

fn glob_starstar(pattern: &[u8], text: &[u8]) -> bool {
    let rest = if pattern.first() == Some(&b'/') {
        &pattern[1..]
    } else {
        pattern
    };
    if rest.is_empty() {
        return true;
    }
    let mut offset = 0;
    loop {
        if glob_bytes(rest, &text[offset..]) {
            return true;
        }
        if offset >= text.len() {
            return false;
        }
        match text[offset..].iter().position(|byte| *byte == b'/') {
            Some(slash) => offset += slash + 1,
            None => offset = text.len(),
        }
    }
}

fn glob_star(pattern: &[u8], text: &[u8]) -> bool {
    let mut offset = 0;
    loop {
        if glob_bytes(pattern, &text[offset..]) {
            return true;
        }
        if offset >= text.len() || text[offset] == b'/' {
            return false;
        }
        offset += 1;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unanchored_extension_and_negation() {
        let mut stack = IgnoreStack::default();
        stack.push_file("", "*.log\n!keep.log\nbuild/\n");
        assert!(stack.is_ignored("debug.log", false));
        assert!(stack.is_ignored("src/debug.log", false));
        assert!(!stack.is_ignored("keep.log", false));
        assert!(stack.is_ignored("build", true));
        assert!(!stack.is_ignored("build.txt", false));
    }

    #[test]
    fn nested_gitignore_is_relative() {
        let mut stack = IgnoreStack::default();
        stack.push_file("pkg", "/dist\n");
        assert!(stack.is_ignored("pkg/dist", true));
        assert!(!stack.is_ignored("dist", true));
    }
}