use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use tracing::warn;
use walkdir::WalkDir;
#[derive(Debug, thiserror::Error)]
pub(crate) enum GitInfoError {
#[error("The repository at {0} is missing a `.git` directory")]
MissingGitDir(PathBuf),
#[error("The repository at {0} is missing a `HEAD` file")]
MissingHead(PathBuf),
#[error("The repository at {0} is missing a `refs` directory")]
MissingRefs(PathBuf),
#[error("The repository at {0} has an invalid reference: `{1}`")]
InvalidRef(PathBuf, String),
#[error("The discovered commit has an invalid length (expected 40 characters): `{0}`")]
WrongLength(String),
#[error("The discovered commit has an invalid character (expected hexadecimal): `{0}`")]
WrongDigit(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub(crate) struct Commit(String);
impl Commit {
pub(crate) fn from_repository(path: &Path) -> Result<Self, GitInfoError> {
let git_dir = path
.ancestors()
.map(|ancestor| ancestor.join(".git"))
.find(|git_dir| git_dir.exists())
.ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?;
let git_head_path =
git_head(&git_dir).ok_or_else(|| GitInfoError::MissingHead(git_dir.clone()))?;
let git_head_contents = fs_err::read_to_string(git_head_path)?;
let mut git_ref_parts = git_head_contents.split_whitespace();
let commit_or_ref = git_ref_parts
.next()
.ok_or_else(|| GitInfoError::InvalidRef(git_dir.clone(), git_head_contents.clone()))?;
let commit = if let Some(git_ref) = git_ref_parts.next() {
let git_ref_path = git_dir.join(git_ref);
let commit = fs_err::read_to_string(git_ref_path)?;
commit.trim().to_string()
} else {
commit_or_ref.to_string()
};
if commit.len() != 40 {
return Err(GitInfoError::WrongLength(commit));
}
if commit.chars().any(|c| !c.is_ascii_hexdigit()) {
return Err(GitInfoError::WrongDigit(commit));
}
Ok(Self(commit))
}
}
#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub(crate) struct Tags(BTreeMap<String, String>);
impl Tags {
pub(crate) fn from_repository(path: &Path) -> Result<Self, GitInfoError> {
let git_dir = path
.ancestors()
.map(|ancestor| ancestor.join(".git"))
.find(|git_dir| git_dir.exists())
.ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?;
let git_tags_path = git_refs(&git_dir)
.ok_or_else(|| GitInfoError::MissingRefs(git_dir.clone()))?
.join("tags");
let mut tags = BTreeMap::new();
for entry in WalkDir::new(&git_tags_path).contents_first(true) {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
warn!("Failed to read Git tags: {err}");
continue;
}
};
let path = entry.path();
if !entry.file_type().is_file() {
continue;
}
if let Ok(Some(tag)) = path.strip_prefix(&git_tags_path).map(|name| name.to_str()) {
let commit = fs_err::read_to_string(path)?.trim().to_string();
if commit.len() != 40 {
return Err(GitInfoError::WrongLength(commit));
}
if commit.chars().any(|c| !c.is_ascii_hexdigit()) {
return Err(GitInfoError::WrongDigit(commit));
}
tags.insert(tag.to_string(), commit);
}
}
Ok(Self(tags))
}
}
fn git_head(git_dir: &Path) -> Option<PathBuf> {
let git_head_path = git_dir.join("HEAD");
if git_head_path.exists() {
return Some(git_head_path);
}
if !git_dir.is_file() {
return None;
}
let contents = fs_err::read_to_string(git_dir).ok()?;
let (label, worktree_path) = contents.split_once(':')?;
if label != "gitdir" {
return None;
}
let worktree_path = worktree_path.trim();
Some(PathBuf::from(worktree_path))
}
fn git_refs(git_dir: &Path) -> Option<PathBuf> {
let git_head_path = git_dir.join("refs");
if git_head_path.exists() {
return Some(git_head_path);
}
if !git_dir.is_file() {
return None;
}
let contents = fs_err::read_to_string(git_dir).ok()?;
let (label, worktree_path) = contents.split_once(':')?;
if label != "gitdir" {
return None;
}
let worktree_path = PathBuf::from(worktree_path.trim());
let refs_path = worktree_path.parent()?.parent()?.join("refs");
Some(refs_path)
}