use std::collections::HashMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use crate::error::{Error, Result};
use crate::index::Index;
use crate::repo::Repository;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IgnoreMatch {
pub source_display: String,
pub line_number: usize,
pub pattern_text: String,
pub negative: bool,
}
#[derive(Debug, Clone)]
struct IgnoreRule {
source_display: String,
line_number: usize,
pattern_text: String,
negative: bool,
directory_only: bool,
anchored: bool,
has_slash: bool,
body: String,
base_dir: String,
}
#[derive(Debug, Default)]
pub struct IgnoreMatcher {
global_rules: Vec<IgnoreRule>,
info_rules: Vec<IgnoreRule>,
gitignore_cache: HashMap<String, Vec<IgnoreRule>>,
}
impl IgnoreMatcher {
pub fn from_repository(repo: &Repository) -> Result<Self> {
Ok(Self {
global_rules: load_global_excludes(repo)?,
info_rules: load_info_excludes(repo)?,
..Self::default()
})
}
pub fn check_path(
&mut self,
repo: &Repository,
index: Option<&Index>,
repo_rel_path: &str,
is_dir: bool,
) -> Result<(bool, Option<IgnoreMatch>)> {
if is_tracked(index, repo_rel_path) {
return Ok((false, None));
}
let mut matched: Option<IgnoreMatch> = None;
let mut ignored = false;
let per_dir_rules = self.rules_for_path(repo, repo_rel_path)?;
for rule in self
.global_rules
.iter()
.chain(self.info_rules.iter())
.chain(per_dir_rules.iter())
{
if rule_matches(rule, repo_rel_path, is_dir) {
matched = Some(IgnoreMatch {
source_display: rule.source_display.clone(),
line_number: rule.line_number,
pattern_text: rule.pattern_text.clone(),
negative: rule.negative,
});
ignored = !rule.negative;
}
}
Ok((ignored, matched))
}
fn rules_for_path(
&mut self,
repo: &Repository,
repo_rel_path: &str,
) -> Result<Vec<IgnoreRule>> {
let parent = parent_dir(repo_rel_path);
let mut dirs = Vec::new();
dirs.push(String::new());
if !parent.is_empty() {
let mut cur = String::new();
for segment in parent.split('/') {
if !cur.is_empty() {
cur.push('/');
}
cur.push_str(segment);
dirs.push(cur.clone());
}
}
for dir in &dirs {
if !self.gitignore_cache.contains_key(dir) {
let rules = load_gitignore_for_dir(repo, dir)?;
self.gitignore_cache.insert(dir.clone(), rules);
}
}
let mut all: Vec<IgnoreRule> = Vec::new();
for dir in dirs {
if let Some(rules) = self.gitignore_cache.get(&dir) {
all.extend(rules.iter().cloned());
}
}
Ok(all)
}
}
fn load_global_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
let config_path = repo.git_dir.join("config");
let Some(config_text) = read_optional_text(&config_path)? else {
return Ok(Vec::new());
};
let Some(raw_path) = parse_core_excludesfile(&config_text) else {
return Ok(Vec::new());
};
let expanded = expand_home(&raw_path);
let resolved = if Path::new(&expanded).is_absolute() {
PathBuf::from(&expanded)
} else if let Some(work_tree) = &repo.work_tree {
work_tree.join(&expanded)
} else {
repo.git_dir.join(&expanded)
};
load_rules_from_file(&resolved, raw_path, String::new())
}
fn load_info_excludes(repo: &Repository) -> Result<Vec<IgnoreRule>> {
let path = repo.git_dir.join("info/exclude");
load_rules_from_file(&path, ".git/info/exclude".to_owned(), String::new())
}
fn load_gitignore_for_dir(repo: &Repository, dir: &str) -> Result<Vec<IgnoreRule>> {
let Some(work_tree) = &repo.work_tree else {
return Ok(Vec::new());
};
let path = if dir.is_empty() {
work_tree.join(".gitignore")
} else {
work_tree.join(dir).join(".gitignore")
};
let source_display = if dir.is_empty() {
".gitignore".to_owned()
} else {
format!("{dir}/.gitignore")
};
load_rules_from_file(&path, source_display, dir.to_owned())
}
fn load_rules_from_file(
path: &Path,
source_display: String,
base_dir: String,
) -> Result<Vec<IgnoreRule>> {
let Some(content) = read_optional_text(path)? else {
return Ok(Vec::new());
};
let mut rules = Vec::new();
for (idx, line) in content.lines().enumerate() {
if let Some(rule) = parse_rule_line(line, idx + 1, &source_display, &base_dir) {
rules.push(rule);
}
}
Ok(rules)
}
fn parse_rule_line(
line: &str,
line_number: usize,
source_display: &str,
base_dir: &str,
) -> Option<IgnoreRule> {
let mut raw = line.trim_end().to_owned();
if raw.is_empty() {
return None;
}
if raw.starts_with('#') {
return None;
}
let mut negative = false;
if let Some(rest) = raw.strip_prefix('!') {
negative = true;
raw = rest.to_owned();
}
if raw.is_empty() {
return None;
}
let mut anchored = false;
if let Some(rest) = raw.strip_prefix('/') {
anchored = true;
raw = rest.to_owned();
}
if raw.is_empty() {
return None;
}
let mut directory_only = false;
if let Some(rest) = raw.strip_suffix('/') {
directory_only = true;
raw = rest.to_owned();
}
if raw.is_empty() {
return None;
}
let has_slash = raw.contains('/');
Some(IgnoreRule {
source_display: source_display.to_owned(),
line_number,
pattern_text: line.trim_end().to_owned(),
negative,
directory_only,
anchored,
has_slash,
body: raw,
base_dir: base_dir.to_owned(),
})
}
fn rule_matches(rule: &IgnoreRule, repo_rel_path: &str, is_dir: bool) -> bool {
let Some(rel_to_base) = strip_base(&rule.base_dir, repo_rel_path) else {
return false;
};
if rule.directory_only {
if rule.has_slash || rule.anchored {
for ancestor in ancestor_dirs(rel_to_base, is_dir) {
if glob_matches(&rule.body, &ancestor) {
return true;
}
}
return false;
}
for ancestor in ancestor_dir_basenames(rel_to_base, is_dir) {
if glob_matches(&rule.body, ancestor) {
return true;
}
}
return false;
}
if rule.has_slash || rule.anchored {
return glob_matches(&rule.body, rel_to_base);
}
path_component_names(rel_to_base)
.iter()
.any(|name| glob_matches(&rule.body, name))
}
fn is_tracked(index: Option<&Index>, repo_rel_path: &str) -> bool {
let Some(index) = index else {
return false;
};
index.entries.iter().any(|entry| {
entry.stage() == 0
&& std::str::from_utf8(&entry.path)
.map(|path| path == repo_rel_path)
.unwrap_or(false)
})
}
fn parse_core_excludesfile(config_text: &str) -> Option<String> {
let mut in_core = false;
for line in config_text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = trimmed
.trim_start_matches('[')
.trim_end_matches(']')
.trim()
.to_ascii_lowercase();
in_core = section == "core";
continue;
}
if !in_core {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("excludesfile") {
return Some(value.trim().to_owned());
}
}
None
}
fn read_optional_text(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(content) => Ok(Some(content)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(Error::Io(err)),
}
}
fn expand_home(path: &str) -> String {
if path == "~" {
return std::env::var("HOME").unwrap_or_else(|_| "~".to_owned());
}
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{home}/{rest}");
}
}
path.to_owned()
}
fn strip_base<'a>(base: &str, path: &'a str) -> Option<&'a str> {
if base.is_empty() {
return Some(path);
}
if path == base {
return Some("");
}
let prefix = format!("{base}/");
path.strip_prefix(&prefix)
}
fn parent_dir(path: &str) -> &str {
match path.rsplit_once('/') {
Some((parent, _)) => parent,
None => "",
}
}
fn path_component_names(path: &str) -> Vec<&str> {
if path.is_empty() {
return Vec::new();
}
path.split('/').collect()
}
fn ancestor_dirs(path: &str, is_dir: bool) -> Vec<String> {
let mut out = Vec::new();
if path.is_empty() {
return out;
}
let parts: Vec<&str> = path.split('/').collect();
let max = if is_dir {
parts.len()
} else {
parts.len().saturating_sub(1)
};
for idx in 1..=max {
out.push(parts[..idx].join("/"));
}
out
}
fn ancestor_dir_basenames(path: &str, is_dir: bool) -> Vec<&str> {
let mut out = Vec::new();
let parts: Vec<&str> = if path.is_empty() {
Vec::new()
} else {
path.split('/').collect()
};
let max = if is_dir {
parts.len()
} else {
parts.len().saturating_sub(1)
};
for item in parts.iter().take(max) {
out.push(*item);
}
out
}
fn glob_matches(pattern: &str, text: &str) -> bool {
wildcard_match(pattern.as_bytes(), text.as_bytes())
}
fn wildcard_match(pattern: &[u8], text: &[u8]) -> bool {
let mut p = 0usize;
let mut t = 0usize;
let mut star_p = None;
let mut star_t = 0usize;
while t < text.len() {
if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == text[t]) {
p += 1;
t += 1;
continue;
}
if p < pattern.len() && pattern[p] == b'*' {
star_p = Some(p);
p += 1;
star_t = t;
continue;
}
if let Some(saved_p) = star_p {
p = saved_p + 1;
star_t += 1;
t = star_t;
continue;
}
return false;
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
pub fn normalize_repo_relative(repo: &Repository, cwd: &Path, path: &str) -> Result<String> {
let Some(work_tree) = &repo.work_tree else {
return Err(Error::PathError(
"this operation must be run in a work tree".to_owned(),
));
};
let input = Path::new(path);
let combined = if input.is_absolute() {
input.to_path_buf()
} else {
cwd.join(input)
};
let normalized = normalize_path(&combined);
let rel = normalized
.strip_prefix(work_tree)
.map_err(|_| Error::PathError(format!("path '{path}' is outside repository work tree")))?;
Ok(path_to_slash(rel))
}
fn normalize_path(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
fn path_to_slash(path: &Path) -> String {
let mut out = String::new();
for (idx, component) in path.components().enumerate() {
if idx > 0 {
out.push('/');
}
out.push_str(&component.as_os_str().to_string_lossy());
}
out
}