use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use tracing::debug;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct VcsInfo {
#[serde(skip_serializing_if = "Option::is_none", rename = "format")]
pub vcs: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision_full: Option<String>,
}
impl VcsInfo {
pub fn from_path(path: &Path) -> Self {
if let Some(info) = Self::try_git(path) {
debug!(
vcs = "git",
local_branch = ?info.local_branch,
remote_branch = ?info.remote_branch,
revision = ?info.revision,
remote = ?info.remote_url,
"Detected repository"
);
return info;
}
if let Some(info) = Self::try_cvs(path) {
debug!(
vcs = "cvs",
remote_branch = ?info.remote_branch,
remote = ?info.remote_url,
"Detected repository"
);
return info;
}
debug!(path = %path.display(), "No VCS detected");
Self::default()
}
pub fn is_detected(&self) -> bool {
self.vcs.is_some()
}
pub fn web_url(&self) -> Option<String> {
if self.vcs.as_deref() != Some("git") {
return None;
}
let url_str = self.remote_url.as_deref()?;
let url = gix::url::parse(url_str.into()).ok()?;
let host = url.host()?;
let path = url.path.to_string();
let path = path.trim_start_matches('/');
match url.scheme {
gix::url::Scheme::Https | gix::url::Scheme::Http => {
Some(format!("{}://{}/{}", url.scheme, host, path))
}
gix::url::Scheme::Ssh => Some(format!("https://{}/{}", host, path)),
_ => None,
}
}
fn try_git(path: &Path) -> Option<Self> {
let repo = gix::discover(path).ok()?;
let head = repo.head().ok()?;
let local_branch = head.referent_name().map(|r| r.shorten().to_string());
let head_id = head.id();
let revision_full = head_id.map(|id| id.to_string());
let revision = revision_full
.as_ref()
.map(|s| s[..s.len().min(12)].to_string());
let (remote_name, remote_branch) = if let Some(ref local) = local_branch {
let config = repo.config_snapshot();
let remote = config
.string(format!("branch.{}.remote", local).as_str())
.map(|v| v.to_string());
let branch = config
.string(format!("branch.{}.merge", local).as_str())
.map(|v| {
let s = v.to_string();
s.trim_start_matches("refs/heads/").to_string()
});
(remote, branch)
} else {
(None, None)
};
let remote_url = remote_name
.or_else(|| {
repo.remote_names()
.into_iter()
.next()
.map(|n| n.to_string())
})
.and_then(|name| repo.find_remote(name.as_str()).ok())
.and_then(|r| r.url(gix::remote::Direction::Fetch).cloned())
.map(|url| {
let s = url.to_bstring().to_string();
s.trim_end_matches(".git").to_string()
});
Some(Self {
vcs: Some("git".to_string()),
remote_url,
local_branch,
remote_branch,
revision,
revision_full,
})
}
pub fn commit_url(&self, sha: &str) -> Option<String> {
Some(format!("{}/commit/{}", self.web_url()?, sha))
}
fn try_cvs(path: &Path) -> Option<Self> {
let root_path = path.join("CVS/Root");
let remote_url = std::fs::read_to_string(root_path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
let tag_path = path.join("CVS/Tag");
let remote_branch = std::fs::read_to_string(tag_path)
.ok()
.map(|s| s.trim().to_string())
.and_then(|s| {
s.strip_prefix('T')
.or_else(|| s.strip_prefix('N'))
.map(|tag| tag.to_string())
})
.or_else(|| Some("HEAD".to_string()));
Some(Self {
vcs: Some("cvs".to_string()),
remote_url: Some(remote_url),
local_branch: None,
remote_branch,
revision: None,
revision_full: None,
})
}
}
#[derive(Clone, Debug)]
pub struct CommitInfo {
pub author: String,
pub sha_full: String,
pub sha_short: String,
}
pub fn commits_for_pkgpaths(
repo_path: &Path,
old_rev: &str,
new_rev: &str,
pkgpaths: &HashSet<String>,
) -> anyhow::Result<HashMap<String, Vec<CommitInfo>>> {
let mut result: HashMap<String, Vec<CommitInfo>> = HashMap::new();
if pkgpaths.is_empty() {
return Ok(result);
}
let repo = gix::discover(repo_path)
.with_context(|| format!("Failed to open git repository at {}", repo_path.display()))?;
let new_id = repo
.rev_parse_single(new_rev)
.with_context(|| format!("Failed to resolve revision {}", new_rev))?
.detach();
let old_id = repo
.rev_parse_single(old_rev)
.with_context(|| format!("Failed to resolve revision {}", old_rev))?
.detach();
let walk = repo
.rev_walk([new_id])
.with_hidden([old_id])
.all()
.context("Failed to start revision walk")?;
let mut walk_count = 0usize;
let mut match_count = 0usize;
for info in walk {
let info = info.context("Revision walk error")?;
walk_count += 1;
let commit = info
.object()
.with_context(|| format!("Failed to load commit {}", info.id))?;
let author = extract_author(&commit);
let sha_full = info.id.to_string();
let sha_short = info.id.to_hex_with_len(7).to_string();
let new_tree = commit.tree().context("Failed to load commit tree")?;
let parent_id = commit.parent_ids().next();
let parent_tree = parent_id
.and_then(|pid| pid.object().ok())
.and_then(|o| o.try_into_commit().ok())
.and_then(|c| c.tree().ok());
let touched = changed_pkgpaths(&repo, &new_tree, parent_tree.as_ref(), pkgpaths)?;
if touched.is_empty() {
continue;
}
match_count += 1;
let entry = CommitInfo {
author,
sha_full,
sha_short,
};
for pkgpath in touched {
result.entry(pkgpath).or_default().push(entry.clone());
}
}
tracing::debug!(
walk_count,
match_count,
"commits_for_pkgpaths walk complete"
);
Ok(result)
}
fn changed_pkgpaths(
repo: &gix::Repository,
new_tree: &gix::Tree<'_>,
old_tree: Option<&gix::Tree<'_>>,
pkgpaths: &HashSet<String>,
) -> anyhow::Result<HashSet<String>> {
use gix::object::tree::diff::Change;
use std::ops::ControlFlow;
let mut touched: HashSet<String> = HashSet::new();
let empty_tree;
let lhs = match old_tree {
Some(t) => t,
None => {
empty_tree = repo.empty_tree();
&empty_tree
}
};
let mut platform = lhs.changes().context("Failed to start tree diff")?;
platform
.for_each_to_obtain_tree(new_tree, |change| {
let path: String = match change {
Change::Addition { location, .. }
| Change::Deletion { location, .. }
| Change::Modification { location, .. } => location.to_string(),
Change::Rewrite {
location,
source_location,
..
} => {
if let Some(pp) = pkgpath_for(source_location.to_string().as_str()) {
if pkgpaths.contains(&pp) {
touched.insert(pp);
}
}
location.to_string()
}
};
if let Some(pp) = pkgpath_for(&path) {
if pkgpaths.contains(&pp) {
touched.insert(pp);
}
}
Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()))
})
.context("Tree diff failed")?;
Ok(touched)
}
fn pkgpath_for(path: &str) -> Option<String> {
let mut parts = path.splitn(3, '/');
let category = parts.next()?;
let pkg = parts.next()?;
if category.is_empty() || pkg.is_empty() {
return None;
}
Some(format!("{}/{}", category, pkg))
}
fn extract_author(commit: &gix::Commit<'_>) -> String {
let Ok(sig) = commit.author() else {
return String::new();
};
let email = sig.email.to_string();
if let Some((local, _)) = email.split_once('@') {
if !local.is_empty() {
return local.to_string();
}
}
sig.name.to_string()
}