use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::{Case, Error, Ignore, InputKind, Limits, Match, Source};
use crate::Repository;
use crate::source_files::{Ancestors, FileError, native, os_path, read_optional};
use crate::workflow::Environment;
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum SourceKind {
Global,
Info,
Directory(Vec<u8>),
}
impl SourceKind {
#[must_use]
pub fn directory(&self) -> Option<&[u8]> {
match self {
Self::Directory(directory) => Some(directory),
Self::Global | Self::Info => None,
}
}
}
#[derive(Clone, Debug)]
pub struct SourceFile {
kind: SourceKind,
path: PathBuf,
content: Vec<u8>,
}
impl SourceFile {
#[must_use]
pub fn kind(&self) -> &SourceKind {
&self.kind
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn content(&self) -> &[u8] {
&self.content
}
#[must_use]
pub fn line(&self, line: usize) -> Option<&[u8]> {
let line = self
.content
.split(|byte| *byte == b'\n')
.nth(line.checked_sub(1)?)?;
Some(line.strip_suffix(b"\r").unwrap_or(line))
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoadError {
#[error("unsupported ignore configuration: {0}")]
Unsupported(&'static str),
#[error("reading ignore configuration")]
Config(#[from] crate::config::InvalidValue),
#[error("reading ignore file {path:?}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("loading ignore file {path:?}")]
Rules {
path: PathBuf,
#[source]
source: Error,
},
#[error("checking ignore rules for {path:?}")]
Check {
path: Vec<u8>,
#[source]
source: Error,
},
}
#[derive(Clone, Debug)]
pub struct WorktreeRules {
root: PathBuf,
base: Ignore,
checked: Ignore,
sources: Vec<SourceFile>,
loaded: BTreeSet<Vec<u8>>,
}
impl WorktreeRules {
#[must_use]
pub fn case(&self) -> Case {
self.base.case()
}
#[must_use]
pub fn limits(&self) -> Limits {
self.base.limits()
}
#[must_use]
pub fn base(&self) -> &Ignore {
&self.base
}
#[must_use]
pub fn source(&self, index: usize) -> Option<&SourceFile> {
self.sources.get(index)
}
pub fn check(&mut self, path: &[u8], kind: InputKind) -> Result<Option<Match>, LoadError> {
let ends = std::iter::once(0).chain(
path.iter()
.enumerate()
.filter_map(|(end, byte)| (*byte == b'/').then_some(end)),
);
let mut walk = Ancestors::open(&self.root)?;
for end in ends {
let directory = path.get(..end).unwrap_or_default();
if !walk.enter(&self.root, directory)? {
break;
}
if self.loaded.contains(directory) {
continue;
}
let file = native(&self.root, directory)
.unwrap_or_else(|| self.root.clone())
.join(".gitignore");
let remaining = self.checked.remaining_bytes();
if let Some(content) = walk.read(".gitignore", &file, remaining)? {
self.push(
SourceKind::Directory(directory.to_vec()),
file,
content,
true,
)?;
}
self.loaded.insert(directory.to_vec());
}
self.checked
.check(path, kind)
.map_err(|source| LoadError::Check {
path: path.to_vec(),
source,
})
}
fn push(
&mut self,
kind: SourceKind,
path: PathBuf,
content: Vec<u8>,
directory_only: bool,
) -> Result<(), LoadError> {
let source = match &kind {
SourceKind::Global => Source::Global,
SourceKind::Info => Source::Info,
SourceKind::Directory(directory) => Source::Directory(directory),
};
let rules = |source| LoadError::Rules {
path: path.clone(),
source,
};
let index = self.checked.add(source, &content).map_err(rules)?;
if !directory_only {
self.base.add(source, &content).map_err(rules)?;
}
debug_assert_eq!(index, self.sources.len());
self.sources.push(SourceFile {
kind,
path,
content,
});
Ok(())
}
}
impl Repository {
pub fn ignore_rules(&self, environment: &Environment) -> Result<WorktreeRules, LoadError> {
self.ignore_rules_with(environment, Limits::default())
}
pub fn ignore_rules_with(
&self,
environment: &Environment,
limits: Limits,
) -> Result<WorktreeRules, LoadError> {
let root = self
.worktree()
.ok_or(LoadError::Unsupported("repository has no working tree"))?
.to_owned();
let case = match self.config().boolean("core", None, "ignorecase")? {
Some(true) => Case::AsciiInsensitive,
_ => Case::Sensitive,
};
let mut rules = WorktreeRules {
base: Ignore::new(case, limits),
checked: Ignore::new(case, limits),
root,
sources: Vec::new(),
loaded: BTreeSet::new(),
};
if let Some(path) = global_excludes(self, &rules.root, environment)?
&& let Some(content) = read_optional(&path, rules.checked.remaining_bytes())?
{
rules.push(SourceKind::Global, path, content, false)?;
}
let exclude = self.common_dir().join("info").join("exclude");
if let Some(content) = read_optional(&exclude, rules.checked.remaining_bytes())? {
rules.push(SourceKind::Info, exclude, content, false)?;
}
Ok(rules)
}
}
fn global_excludes(
repository: &Repository,
root: &Path,
environment: &Environment,
) -> Result<Option<PathBuf>, LoadError> {
let Some(value) = repository.config().string("core", None, "excludesfile") else {
let xdg = environment
.get("XDG_CONFIG_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| {
environment
.get("HOME")
.map(|home| Path::new(home).join(".config"))
});
return Ok(xdg.map(|directory| directory.join("git").join("ignore")));
};
if value.is_empty() {
return Ok(None);
}
if let Some(rest) = value.strip_prefix(b"~/") {
let home = environment.get("HOME").ok_or(LoadError::Unsupported(
"core.excludesFile uses ~/ but HOME is unset",
))?;
return Ok(Some(Path::new(home).join(excludes_path(rest)?)));
}
if value.starts_with(b"~") {
return Err(LoadError::Unsupported(
"core.excludesFile uses ~user/ expansion",
));
}
Ok(Some(root.join(excludes_path(value)?)))
}
fn excludes_path(bytes: &[u8]) -> Result<PathBuf, LoadError> {
os_path(bytes).ok_or(LoadError::Unsupported("core.excludesFile is not Unicode"))
}
impl From<FileError> for LoadError {
fn from(error: FileError) -> Self {
match error {
FileError::Io { path, source } => Self::Io { path, source },
FileError::TooLarge { path } => Self::Rules {
path,
source: Error::Limit("source bytes"),
},
FileError::NotFile => Self::Unsupported("ignore file is not a regular file"),
}
}
}