use std::collections::BTreeSet;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use super::{Case, Error, Ignore, InputKind, Limits, Match, Source};
use crate::Repository;
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(os_path(rest)?)));
}
if value.starts_with(b"~") {
return Err(LoadError::Unsupported(
"core.excludesFile uses ~user/ expansion",
));
}
Ok(Some(root.join(os_path(value)?)))
}
fn os_path(bytes: &[u8]) -> Result<PathBuf, LoadError> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(bytes)))
}
#[cfg(not(unix))]
{
std::str::from_utf8(bytes)
.map(PathBuf::from)
.map_err(|_| LoadError::Unsupported("core.excludesFile is not Unicode"))
}
}
fn native(root: &Path, directory: &[u8]) -> Option<PathBuf> {
let mut path = root.to_owned();
if directory.is_empty() {
return Some(path);
}
for component in directory.split(|byte| *byte == b'/') {
if component.is_empty() || component == b"." || component == b".." {
return None;
}
path.push(os_path(component).ok()?);
}
Some(path)
}
fn io(path: &Path, source: impl Into<std::io::Error>) -> LoadError {
LoadError::Io {
path: path.to_owned(),
source: source.into(),
}
}
fn read_bounded(
file: impl std::io::Read,
path: &Path,
remaining: usize,
) -> Result<Vec<u8>, LoadError> {
use std::io::Read as _;
let mut content = Vec::new();
let limit = u64::try_from(remaining)
.unwrap_or(u64::MAX)
.saturating_add(1);
file.take(limit)
.read_to_end(&mut content)
.map_err(|source| io(path, source))?;
if content.len() > remaining {
return Err(LoadError::Rules {
path: path.to_owned(),
source: Error::Limit("source bytes"),
});
}
Ok(content)
}
fn read_optional(path: &Path, remaining: usize) -> Result<Option<Vec<u8>>, LoadError> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(rustix::fs::OFlags::NONBLOCK.bits().cast_signed());
}
let file = match options.open(path) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(io(path, source)),
};
if !file
.metadata()
.map_err(|source| io(path, source))?
.is_file()
{
return Err(LoadError::Unsupported("ignore file is not a regular file"));
}
read_bounded(file, path, remaining).map(Some)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
struct Ancestors {
directory: Option<rustix::fd::OwnedFd>,
depth: usize,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Ancestors {
fn open(root: &Path) -> Result<Self, LoadError> {
let directory = crate::status::worktree::open_root(root).map_err(|_| {
io(
root,
std::io::Error::other("cannot open the working tree without following symlinks"),
)
})?;
Ok(Self {
directory: Some(directory),
depth: 0,
})
}
fn enter(&mut self, root: &Path, directory: &[u8]) -> Result<bool, LoadError> {
use std::os::unix::ffi::OsStrExt;
let Some(current) = &self.directory else {
return Ok(false);
};
let components = directory
.split(|byte| *byte == b'/')
.filter(|c| !c.is_empty());
let Some(name) = components.clone().nth(self.depth) else {
return Ok(true);
};
if components.count() != self.depth + 1 || name == b"." || name == b".." {
self.directory = None;
return Ok(false);
}
match rustix::fs::openat(
current,
std::ffi::OsStr::from_bytes(name),
crate::status::worktree::directory_flags(),
rustix::fs::Mode::empty(),
) {
Ok(next) => {
self.directory = Some(next);
self.depth += 1;
Ok(true)
}
Err(rustix::io::Errno::NOENT | rustix::io::Errno::NOTDIR | rustix::io::Errno::LOOP) => {
self.directory = None;
Ok(false)
}
Err(source) => Err(io(&native(root, directory).unwrap_or_default(), source)),
}
}
fn read_gitignore(&self, path: &Path, remaining: usize) -> Result<Option<Vec<u8>>, LoadError> {
use rustix::fs::{FileType, Mode, OFlags};
let Some(directory) = &self.directory else {
return Ok(None);
};
let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
let file = match rustix::fs::openat(directory, ".gitignore", flags, Mode::empty()) {
Ok(file) => file,
Err(rustix::io::Errno::NOENT | rustix::io::Errno::LOOP) => return Ok(None),
Err(source) => return Err(io(path, source)),
};
let stat = rustix::fs::fstat(&file).map_err(|source| io(path, source))?;
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
return Ok(None);
}
read_bounded(std::fs::File::from(file), path, remaining).map(Some)
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
struct Ancestors {
blocked: bool,
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
impl Ancestors {
fn open(_root: &Path) -> Result<Self, LoadError> {
Ok(Self { blocked: false })
}
fn enter(&mut self, root: &Path, directory: &[u8]) -> Result<bool, LoadError> {
self.blocked = self.blocked
|| !native(root, directory)
.and_then(|folder| std::fs::symlink_metadata(folder).ok())
.is_some_and(|metadata| metadata.is_dir());
Ok(!self.blocked)
}
fn read_gitignore(&self, path: &Path, remaining: usize) -> Result<Option<Vec<u8>>, LoadError> {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_file() => {
let file = std::fs::File::open(path).map_err(|source| io(path, source))?;
read_bounded(file, path, remaining).map(Some)
}
Ok(_) => Ok(None),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(source) => Err(io(path, source)),
}
}
}