use anyhow::{Context, Result};
use chrono::{DateTime, FixedOffset, TimeZone};
use gix::ObjectId;
use rust_i18n::t;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub sha: String,
pub short_sha: String,
pub message: String,
pub when: DateTime<FixedOffset>,
pub parent_count: usize,
pub parents: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct TagInfo {
pub name: String,
pub target_sha: String,
pub when: DateTime<FixedOffset>,
}
pub struct GitRepo {
repo: gix::Repository,
}
fn gix_time_to_chrono(t: gix::date::Time) -> DateTime<FixedOffset> {
let offset =
FixedOffset::east_opt(t.offset).unwrap_or_else(|| FixedOffset::east_opt(0).unwrap());
offset
.timestamp_opt(t.seconds, 0)
.single()
.unwrap_or_else(|| offset.timestamp_opt(0, 0).unwrap())
}
impl GitRepo {
pub fn discover(path: &Path) -> Result<Self> {
let repo =
gix::discover(path).with_context(|| t!("git.repo_not_found", path = path.display()))?;
Ok(Self { repo })
}
pub fn workdir(&self) -> Option<&Path> {
self.repo.workdir()
}
pub fn git_dir(&self) -> &Path {
self.repo.git_dir()
}
pub fn head_ref_name(&self) -> String {
match self.repo.head_name() {
Ok(Some(name)) => name.as_bstr().to_string(),
_ => self
.head_commit()
.map(|c| c.short_sha)
.unwrap_or_else(|_| "HEAD".into()),
}
}
pub fn refs_snapshot(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
if let Ok(platform) = self.repo.references() {
if let Ok(iter) = platform.all() {
for reference in iter.flatten() {
let name = reference.name().as_bstr().to_string();
let target = reference
.clone()
.into_fully_peeled_id()
.map(|id| id.to_string())
.unwrap_or_default();
out.push(format!("{name} {target}"));
}
}
}
out.sort();
Ok(out)
}
fn commit_info(commit: &gix::Commit<'_>) -> Result<CommitInfo> {
let sha = commit.id().to_string();
let when = gix_time_to_chrono(commit.time()?);
let message = commit
.message_raw()
.map(|m| m.to_string())
.unwrap_or_default();
let parents: Vec<String> = commit.parent_ids().map(|id| id.to_string()).collect();
Ok(CommitInfo {
short_sha: sha[..7.min(sha.len())].to_string(),
sha,
message,
when,
parent_count: parents.len(),
parents,
})
}
pub fn head_commit(&self) -> Result<CommitInfo> {
let commit = self
.repo
.head_commit()
.with_context(|| t!("git.head_read").to_string())?;
Self::commit_info(&commit)
}
pub fn current_branch_name(&self) -> Result<String> {
if let Some(name) = self.repo.head_name()? {
Ok(name.shorten().to_string())
} else {
let head_sha = self.repo.head_commit()?.id().to_string();
let containing = self.branches_containing(&head_sha);
if containing.len() == 1 {
Ok(containing.into_iter().next().unwrap())
} else {
Ok("(no branch)".to_string())
}
}
}
fn local_branches_at(&self, sha: &str) -> Vec<String> {
let mut out = Vec::new();
if let Ok(platform) = self.repo.references() {
if let Ok(branches) = platform.local_branches() {
for reference in branches.flatten() {
if let Ok(id) = reference.clone().into_fully_peeled_id() {
if id.to_string() == sha {
out.push(reference.name().shorten().to_string());
}
}
}
}
}
out
}
fn branches_containing(&self, head_sha: &str) -> Vec<String> {
let direct = self.local_branches_at(head_sha);
if !direct.is_empty() {
return direct;
}
let mut out = Vec::new();
if let Ok(platform) = self.repo.references() {
if let Ok(branches) = platform.local_branches() {
for reference in branches.flatten() {
if let Ok(id) = reference.clone().into_fully_peeled_id() {
let tip = id.to_string();
if self.is_ancestor_of(head_sha, &tip).unwrap_or(false) {
out.push(reference.name().shorten().to_string());
}
}
}
}
}
out
}
fn resolve(&self, spec: &str) -> Option<ObjectId> {
let id = self.repo.rev_parse_single(spec).ok()?;
let commit = id.object().ok()?.try_into_commit().ok()?;
Some(commit.id)
}
pub fn tags(&self) -> Result<Vec<TagInfo>> {
let mut out = Vec::new();
let platform = self.repo.references()?;
for reference in platform.tags()?.flatten() {
let name = reference.name().shorten().to_string();
if let Ok(id) = reference.clone().into_fully_peeled_id() {
let commit = id.object().ok().and_then(|o| o.try_into_commit().ok());
if let Some(commit) = commit {
if let Ok(time) = commit.time() {
out.push(TagInfo {
name,
target_sha: commit.id().to_string(),
when: gix_time_to_chrono(time),
});
}
}
}
}
Ok(out)
}
pub fn branch_names(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
let platform = self.repo.references()?;
for reference in platform.local_branches()?.flatten() {
out.push(reference.name().shorten().to_string());
}
for reference in self.repo.references()?.remote_branches()?.flatten() {
out.push(reference.name().shorten().to_string());
}
Ok(out)
}
pub fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<CommitInfo>> {
let to_oid = self
.resolve(to)
.with_context(|| t!("git.commit_not_found", commit = to))?;
let mut platform = self.repo.rev_walk([to_oid]);
if let Some(f) = from {
if let Some(f_oid) = self.resolve(f) {
platform = platform.with_hidden([f_oid]);
}
}
let mut out = Vec::new();
for info in platform.all()? {
let info = info?;
if let Ok(commit) = self.repo.find_commit(info.id) {
out.push(Self::commit_info(&commit)?);
}
}
Ok(out)
}
pub fn first_parent_between(&self, from: Option<&str>, to: &str) -> Result<Vec<CommitInfo>> {
let to_oid = self
.resolve(to)
.with_context(|| t!("git.commit_not_found", commit = to))?;
let mut platform = self.repo.rev_walk([to_oid]).first_parent_only();
if let Some(f) = from {
if let Some(f_oid) = self.resolve(f) {
platform = platform.with_hidden([f_oid]);
}
}
let mut out = Vec::new();
for info in platform.all()? {
let info = info?;
if let Ok(commit) = self.repo.find_commit(info.id) {
out.push(Self::commit_info(&commit)?);
}
}
Ok(out)
}
pub fn merge_base(&self, a: &str, b: &str) -> Result<Option<String>> {
let (oid_a, oid_b) = match (self.resolve(a), self.resolve(b)) {
(Some(x), Some(y)) => (x, y),
_ => return Ok(None),
};
match self.repo.merge_base(oid_a, oid_b) {
Ok(base) => Ok(Some(base.to_string())),
Err(_) => Ok(None),
}
}
pub fn is_ancestor_of_head(&self, sha: &str) -> Result<bool> {
let head = self.head_commit()?;
self.is_ancestor_of(sha, &head.sha)
}
pub fn is_ancestor_of(&self, ancestor: &str, descendant: &str) -> Result<bool> {
let (a, d) = match (self.resolve(ancestor), self.resolve(descendant)) {
(Some(a), Some(d)) => (a, d),
_ => return Ok(false),
};
if a == d {
return Ok(true);
}
match self.repo.merge_base(a, d) {
Ok(base) => Ok(base.detach() == a),
Err(_) => Ok(false),
}
}
pub fn changed_paths_for_commit(&self, sha: &str) -> Vec<String> {
(|| -> Option<Vec<String>> {
let oid = self.resolve(sha)?;
let commit = self.repo.find_commit(oid).ok()?;
let new_tree = commit.tree().ok()?;
let parent = commit
.parent_ids()
.next()
.and_then(|pid| self.repo.find_commit(pid).ok())?;
let old_tree = parent.tree().ok()?;
let mut paths: Vec<String> = Vec::new();
let mut platform = old_tree.changes().ok()?;
platform.options(|o| {
o.track_path();
o.track_rewrites(None);
});
let _ = platform.for_each_to_obtain_tree(&new_tree, |change| {
paths.push(change.location().to_string());
Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Continue(()))
});
Some(paths)
})()
.unwrap_or_default()
}
pub fn commit_info_of(&self, spec: &str) -> Option<CommitInfo> {
let id = self.resolve(spec)?;
let commit = self.repo.find_commit(id).ok()?;
Self::commit_info(&commit).ok()
}
pub fn local_branch_names(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
let platform = self.repo.references()?;
for reference in platform.local_branches()?.flatten() {
out.push(reference.name().shorten().to_string());
}
out.sort();
Ok(out)
}
pub fn create_tag(&self, name: &str, target_spec: Option<&str>) -> Result<()> {
let target = match target_spec {
Some(s) => self
.resolve(s)
.with_context(|| t!("git.target_commit_not_found").to_string())?,
None => self.repo.head_commit()?.id,
};
self.repo
.reference(
format!("refs/tags/{name}"),
target,
gix::refs::transaction::PreviousValue::MustNotExist,
format!("gitversion: create tag {name}"),
)
.with_context(|| t!("git.tag_create_failed", name = name))?;
Ok(())
}
pub fn create_branch(&self, name: &str, target_spec: Option<&str>) -> Result<()> {
let target = match target_spec {
Some(s) => self
.resolve(s)
.with_context(|| t!("git.target_commit_not_found").to_string())?,
None => self.repo.head_commit()?.id,
};
self.repo
.reference(
format!("refs/heads/{name}"),
target,
gix::refs::transaction::PreviousValue::MustNotExist,
format!("gitversion: create branch {name}"),
)
.with_context(|| t!("git.branch_create_failed", name = name))?;
Ok(())
}
pub fn clear_cache(&self) -> Result<usize> {
let dir = self.git_dir().join("gitversion_cache");
if !dir.exists() {
return Ok(0);
}
let count = std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0);
std::fs::remove_dir_all(&dir)
.with_context(|| t!("git.cache_clear_failed", path = dir.display()))?;
Ok(count)
}
pub fn uncommitted_changes(&self) -> Result<i64> {
let status = match self.repo.status(gix::progress::Discard) {
Ok(s) => s,
Err(_) => return Ok(0),
};
let iter = match status.into_index_worktree_iter(Vec::new()) {
Ok(it) => it,
Err(_) => return Ok(0),
};
Ok(iter.flatten().count() as i64)
}
pub fn tags_on_commit(&self, sha: &str) -> Result<HashSet<String>> {
Ok(self
.tags()?
.into_iter()
.filter(|t| t.target_sha == sha)
.map(|t| t.name)
.collect())
}
}