use std::path::{Path, PathBuf};
use circus_common::error::{CiError, Result};
use git2::Repository;
const FETCH_REFSPECS_REQUIRED: &[&str] = &[
"refs/heads/*:refs/remotes/origin/*",
"refs/tags/*:refs/tags/*",
];
const FETCH_REFSPECS_OPTIONAL: &[&str] = &[
"refs/pull/*/head:refs/remotes/origin/pr/*",
"refs/merge-requests/*/head:refs/remotes/origin/mr/*",
];
fn fetch_all_refs(repo: &Repository) -> Result<()> {
let mut remote = repo.find_remote("origin")?;
remote.fetch(FETCH_REFSPECS_REQUIRED, None, None)?;
for spec in FETCH_REFSPECS_OPTIONAL {
if let Err(e) = remote.fetch(&[*spec], None, None) {
tracing::debug!(refspec = spec, "Optional fetch failed: {e}");
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RefKind {
Branch,
Tag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredRef {
pub kind: RefKind,
pub name: String,
pub commit_hash: String,
}
fn glob_matches(pattern: &str, value: &str) -> bool {
fn inner(pattern: &[u8], value: &[u8]) -> bool {
match pattern {
[] => value.is_empty(),
[b'*', rest @ ..] => {
inner(rest, value) || (!value.is_empty() && inner(pattern, &value[1..]))
},
[b'?', rest @ ..] => !value.is_empty() && inner(rest, &value[1..]),
[ch, rest @ ..] => {
value.first().is_some_and(|v| v == ch) && inner(rest, &value[1..])
},
}
}
inner(pattern.as_bytes(), value.as_bytes())
}
fn resolve_ref(
repo: &Repository,
git_ref: &str,
) -> Result<(String, git2::Oid)> {
let reference = repo.find_reference(git_ref)?;
let commit = reference.peel_to_commit()?;
Ok((commit.id().to_string(), commit.id()))
}
fn clone_or_open_and_fetch(
url: &str,
work_dir: &Path,
project_name: &str,
) -> Result<(PathBuf, Repository, bool)> {
let repo_path = work_dir.join(project_name);
let is_fetch = repo_path.exists();
let repo = if is_fetch {
let repo = Repository::open(&repo_path)?;
fetch_all_refs(&repo)?;
repo
} else {
let repo = Repository::clone(url, &repo_path)?;
fetch_all_refs(&repo)?;
repo
};
Ok((repo_path, repo, is_fetch))
}
pub fn list_matching_refs(
url: &str,
work_dir: &Path,
project_name: &str,
branch_pattern: Option<&str>,
tag_pattern: Option<&str>,
) -> Result<Vec<DiscoveredRef>> {
let (_repo_path, repo, _is_fetch) =
clone_or_open_and_fetch(url, work_dir, project_name)?;
let mut refs = Vec::new();
if let Some(pattern) = branch_pattern {
for reference in repo.references_glob("refs/remotes/origin/*")? {
let reference = reference?;
let Some(name) = reference
.name()
.ok()
.and_then(|name| name.strip_prefix("refs/remotes/origin/"))
else {
continue;
};
if name == "HEAD" || !glob_matches(pattern, name) {
continue;
}
let commit = reference.peel_to_commit()?;
refs.push(DiscoveredRef {
kind: RefKind::Branch,
name: name.to_string(),
commit_hash: commit.id().to_string(),
});
}
}
if let Some(pattern) = tag_pattern {
for reference in repo.references_glob("refs/tags/*")? {
let reference = reference?;
let Some(name) = reference
.name()
.ok()
.and_then(|name| name.strip_prefix("refs/tags/"))
else {
continue;
};
if !glob_matches(pattern, name) {
continue;
}
let commit = reference.peel_to_commit()?;
refs.push(DiscoveredRef {
kind: RefKind::Tag,
name: name.to_string(),
commit_hash: commit.id().to_string(),
});
}
}
refs.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.cmp(&b.name)));
refs.dedup_by(|a, b| a.kind == b.kind && a.name == b.name);
Ok(refs)
}
#[tracing::instrument(skip(work_dir))]
pub fn clone_or_fetch(
url: &str,
work_dir: &Path,
project_name: &str,
branch: Option<&str>,
) -> Result<(PathBuf, String)> {
let (repo_path, repo, is_fetch) =
clone_or_open_and_fetch(url, work_dir, project_name)?;
let branch_name = if let Some(b) = branch {
b.to_string()
} else {
let head = repo.head()?;
head.shorthand().unwrap_or("master").to_string()
};
let git_ref = if branch_name.starts_with("refs/") {
branch_name.clone()
} else {
format!("refs/remotes/origin/{branch_name}")
};
let (hash, oid) = resolve_ref(&repo, &git_ref).map_err(|e| {
CiError::NotFound(format!(
"Git ref '{branch_name}' not found ({git_ref}): {e}"
))
})?;
let commit = repo.find_commit(oid)?;
if is_fetch {
repo.checkout_tree(
commit.as_object(),
Some(git2::build::CheckoutBuilder::new().force()),
)?;
repo.set_head_detached(commit.id())?;
}
Ok((repo_path, hash))
}
pub fn checkout_named_ref(
url: &str,
work_dir: &Path,
project_name: &str,
kind: RefKind,
name: &str,
) -> Result<(PathBuf, String)> {
let git_ref = match kind {
RefKind::Branch => format!("refs/remotes/origin/{name}"),
RefKind::Tag => format!("refs/tags/{name}"),
};
let (repo_path, repo, _is_fetch) =
clone_or_open_and_fetch(url, work_dir, project_name)?;
let (hash, oid) = resolve_ref(&repo, &git_ref).map_err(|e| {
CiError::NotFound(format!("Git ref '{git_ref}' not found: {e}"))
})?;
let commit = repo.find_commit(oid)?;
repo.checkout_tree(
commit.as_object(),
Some(git2::build::CheckoutBuilder::new().force()),
)?;
repo.set_head_detached(commit.id())?;
Ok((repo_path, hash))
}
#[tracing::instrument(skip(work_dir))]
pub fn fetch_and_checkout_commit(
url: &str,
work_dir: &Path,
project_name: &str,
commit_sha: &str,
) -> Result<PathBuf> {
let repo_path = work_dir.join(project_name);
let repo = if repo_path.exists() {
Repository::open(&repo_path)?
} else {
Repository::clone(url, &repo_path)?
};
fetch_all_refs(&repo)?;
let oid = git2::Oid::from_str(commit_sha).map_err(|e| {
CiError::Validation(format!("Invalid commit SHA '{commit_sha}': {e}"))
})?;
let commit = repo.find_commit(oid).map_err(|e| {
CiError::NotFound(format!(
"Commit {commit_sha} not reachable on origin (fetched branches and \
pull/merge-request refs): {e}"
))
})?;
repo.checkout_tree(
commit.as_object(),
Some(git2::build::CheckoutBuilder::new().force()),
)?;
repo.set_head_detached(commit.id())?;
Ok(repo_path)
}