use std::collections::HashSet;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use crate::config::parse_path;
use crate::error::{Error, Result};
use crate::objects::ObjectId;
use crate::repo::Repository;
use crate::rev_parse::resolve_revision_as_commit;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IgnoreRevsSources {
pub files: Vec<String>,
pub revs: Vec<String>,
pub use_config: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IgnoreRevs {
revs: HashSet<ObjectId>,
}
impl IgnoreRevs {
pub fn load(repo: &Repository, sources: &IgnoreRevsSources) -> Result<Self> {
let mut revs = HashSet::new();
let config = repo.config().map(|c| (*c).clone()).unwrap_or_default();
if sources.use_config {
for path in config.get_all("blame.ignorerevsfile") {
if path.is_empty() {
revs.clear();
} else {
load_file_into(repo, &path, &mut revs)?;
}
}
}
for path in &sources.files {
if path.is_empty() {
revs.clear();
} else {
load_file_into(repo, path, &mut revs)?;
}
}
for spec in &sources.revs {
let oid = resolve_revision_as_commit(repo, spec)
.map_err(|_| Error::BlameIgnoreRevUnknown { rev: spec.clone() })?;
revs.insert(oid);
}
Ok(Self { revs })
}
#[must_use]
pub fn as_set(&self) -> &HashSet<ObjectId> {
&self.revs
}
}
fn resolve_ignore_revs_file_path(repo: &Repository, path: &str) -> Option<PathBuf> {
let (optional, body) = match path.strip_prefix(":(optional)") {
Some(rest) => (true, rest),
None => (false, path),
};
let expanded = parse_path(repo.environment(), body);
let p = Path::new(&expanded);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else if let Some(wt) = repo.work_tree() {
wt.join(p)
} else {
repo.git_dir().join(p)
};
if optional && !resolved.is_file() {
return None;
}
Some(resolved)
}
fn load_file_into(repo: &Repository, path: &str, revs: &mut HashSet<ObjectId>) -> Result<()> {
let Some(resolved) = resolve_ignore_revs_file_path(repo, path) else {
return Ok(());
};
let file = fs::File::open(&resolved).map_err(|e| Error::BlameIgnoreRevsFileOpen {
path: path.to_owned(),
source: e,
})?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
parse_ignore_revs_line(repo, path, &line, revs)?;
}
Ok(())
}
fn parse_ignore_revs_line(
repo: &Repository,
file_path: &str,
line: &str,
revs: &mut HashSet<ObjectId>,
) -> Result<()> {
let trimmed = line
.split('#')
.next()
.unwrap_or(line)
.trim()
.trim_end_matches('\r');
if trimmed.is_empty() {
return Ok(());
}
let oid = ObjectId::from_hex(trimmed).map_err(|_| Error::BlameIgnoreRevsFileInvalidRev {
path: file_path.to_owned(),
rev: trimmed.to_owned(),
})?;
let expected_len = repo.odb().hash_algo().hex_len();
if trimmed.len() != expected_len {
return Err(Error::BlameIgnoreRevsFileInvalidRev {
path: file_path.to_owned(),
rev: trimmed.to_owned(),
});
}
match repo.odb().peel_to_commit(oid) {
Ok(Some(commit)) => {
revs.insert(commit);
}
Ok(None) => {}
Err(Error::ObjectNotFound(_)) => {}
Err(e) => return Err(e),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::objects::HashAlgo;
use crate::repo::{InitOptions, Repository};
use grit_test_support::objects::git_supports_sha256;
fn init_repo(dir: &Path) -> Repository {
Repository::init(dir, &InitOptions::default()).expect("init")
}
#[test]
fn parse_line_skips_comments_and_blank_lines() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let mut revs = HashSet::new();
parse_ignore_revs_line(&repo, "revs.txt", "# only comment", &mut revs).unwrap();
parse_ignore_revs_line(&repo, "revs.txt", " ", &mut revs).unwrap();
assert!(revs.is_empty());
}
#[test]
fn parse_line_rejects_abbreviated_oid() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let mut revs = HashSet::new();
let err = parse_ignore_revs_line(&repo, "f", "deadbeef", &mut revs).unwrap_err();
assert!(matches!(err, Error::BlameIgnoreRevsFileInvalidRev { .. }));
}
#[test]
fn parse_line_rejects_invalid_hex() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let mut revs = HashSet::new();
let hex = "g".repeat(40);
let err = parse_ignore_revs_line(&repo, "f", &hex, &mut revs).unwrap_err();
assert!(matches!(err, Error::BlameIgnoreRevsFileInvalidRev { .. }));
}
#[test]
fn parse_line_accepts_crlf_suffix() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
std::process::Command::new("git")
.current_dir(dir.path())
.args(["init"])
.status()
.unwrap();
std::fs::write(dir.path().join("f"), "x").unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.args(["add", "f"])
.status()
.unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@e.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@e.com")
.args(["commit", "-m", "c"])
.status()
.unwrap();
let hex = std::process::Command::new("git")
.current_dir(dir.path())
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let hex = String::from_utf8_lossy(&hex.stdout).trim().to_string();
let mut revs = HashSet::new();
parse_ignore_revs_line(&repo, "f", &format!("{hex}\r"), &mut revs).unwrap();
assert_eq!(revs.len(), 1);
}
#[test]
fn parse_line_rejects_wrong_length_in_sha256_repo() {
if !git_supports_sha256() {
return;
}
let dir = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.current_dir(dir.path())
.args(["init", "--object-format=sha256"])
.status()
.unwrap();
let repo = Repository::open(
&dir.path().join(".git"),
&crate::config::environment::RepositoryOptions::empty().with_work_tree(dir.path()),
)
.unwrap();
assert_eq!(repo.odb().hash_algo(), HashAlgo::Sha256);
let mut revs = HashSet::new();
let forty = "a".repeat(40);
let err = parse_ignore_revs_line(&repo, "f", &forty, &mut revs).unwrap_err();
assert!(matches!(err, Error::BlameIgnoreRevsFileInvalidRev { .. }));
}
#[test]
fn parse_line_skips_missing_full_length_oid() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let mut revs = HashSet::new();
parse_ignore_revs_line(
&repo,
"f",
"0000000000000000000000000000000000000001",
&mut revs,
)
.unwrap();
assert!(revs.is_empty());
}
#[test]
fn optional_ignore_revs_file_path_is_skipped_when_missing() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let loaded = IgnoreRevs::load(
&repo,
&IgnoreRevsSources {
files: vec![":(optional)no-such-ignore-revs-file".into()],
..Default::default()
},
)
.unwrap();
assert!(loaded.revs.is_empty());
}
#[test]
fn empty_file_entry_clears_prior_revs() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let loaded = IgnoreRevs::load(
&repo,
&IgnoreRevsSources {
files: vec![String::new()],
..Default::default()
},
)
.unwrap();
assert!(loaded.revs.is_empty());
}
}