use std::{
fmt::Display,
path::{Path, PathBuf},
process::Command,
str::FromStr,
sync::LazyLock,
};
use reqwest::StatusCode;
use reqwest_middleware::ClientWithMiddleware;
use url::Url;
use crate::{
sha::{GitOid, GitSha},
GitError,
};
const CHECKOUT_READY_LOCK: &str = ".ok";
pub const GIT_DIR: &str = "GIT_DIR";
#[derive(Debug, thiserror::Error, Clone)]
pub enum GitBinaryError {
#[error("Git executable not found. Ensure that Git is installed and available.")]
GitNotFound,
#[error(transparent)]
Other(#[from] which::Error),
}
pub static GIT: LazyLock<Result<PathBuf, GitBinaryError>> = LazyLock::new(|| {
which::which("git").map_err(|e| match e {
which::Error::CannotFindBinaryPath => GitBinaryError::GitNotFound,
e => GitBinaryError::Other(e),
})
});
enum RefspecStrategy {
All,
First,
}
#[derive(
Debug,
Clone,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
Default,
)]
#[serde(rename_all = "kebab-case")]
pub enum GitReference {
Branch(String),
Tag(String),
ShortCommit(String),
BranchOrTag(String),
BranchOrTagOrCommit(String),
NamedRef(String),
FullCommit(String),
#[default]
DefaultBranch,
}
impl GitReference {
pub fn from_rev(rev: String) -> Self {
if rev.starts_with("refs/") {
Self::NamedRef(rev)
} else if GitReference::looks_like_commit_hash(&rev) {
if rev.len() == 40 {
Self::FullCommit(rev)
} else {
Self::BranchOrTagOrCommit(rev)
}
} else {
Self::BranchOrTag(rev)
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Tag(rev)
| Self::Branch(rev)
| Self::ShortCommit(rev)
| Self::BranchOrTag(rev)
| Self::BranchOrTagOrCommit(rev)
| Self::FullCommit(rev)
| Self::NamedRef(rev) => Some(rev),
Self::DefaultBranch => None,
}
}
pub(crate) fn as_rev(&self) -> &str {
match self {
Self::Tag(rev)
| Self::Branch(rev)
| Self::ShortCommit(rev)
| Self::BranchOrTag(rev)
| Self::BranchOrTagOrCommit(rev)
| Self::FullCommit(rev)
| Self::NamedRef(rev) => rev,
Self::DefaultBranch => "HEAD",
}
}
pub(crate) fn as_sha(&self) -> Option<GitSha> {
if let Self::FullCommit(rev) = self {
Some(GitSha::from_str(rev).expect("Full commit should be exactly 40 characters"))
} else {
None
}
}
pub(crate) fn resolve(&self, repo: &GitRepository) -> Result<GitOid, GitError> {
match self {
Self::Tag(s) => repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")),
Self::Branch(s) => repo.rev_parse(&format!("origin/{s}^0")),
Self::BranchOrTag(s) => repo
.rev_parse(&format!("origin/{s}^0"))
.or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
Self::BranchOrTagOrCommit(s) => repo
.rev_parse(&format!("{s}^0"))
.or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
.or_else(|_| repo.rev_parse(&format!("origin/{s}^0"))),
Self::DefaultBranch => repo.rev_parse("refs/remotes/origin/HEAD"),
Self::FullCommit(s) | Self::ShortCommit(s) | Self::NamedRef(s) => {
repo.rev_parse(&format!("{s}^0"))
}
}
}
pub fn looks_like_commit_hash(rev: &str) -> bool {
rev.len() >= 7 && rev.chars().all(|ch| ch.is_ascii_hexdigit())
}
pub fn looks_like_full_commit_hash(rev: &str) -> bool {
rev.len() == 40 && rev.chars().all(|ch| ch.is_ascii_hexdigit())
}
pub fn is_default(&self) -> bool {
matches!(self, Self::DefaultBranch)
}
}
impl Display for GitReference {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str().unwrap_or("HEAD"))
}
}
#[derive(PartialEq, Clone, Debug)]
pub(crate) struct GitRemote {
url: Url,
}
impl GitRemote {
pub(crate) fn new(url: &Url) -> Self {
Self { url: url.clone() }
}
pub(crate) fn checkout(
&self,
into: &Path,
db: Option<GitDatabase>,
reference: &GitReference,
locked_rev: Option<GitOid>,
client: &ClientWithMiddleware,
) -> Result<(GitDatabase, GitOid), GitError> {
let locked_ref = locked_rev.map(|oid| GitReference::FullCommit(oid.to_string()));
let reference = locked_ref.as_ref().unwrap_or(reference);
if let Some(mut db) = db {
fetch(&mut db.repo, self.url.as_str(), reference, client)?;
let resolved_commit_hash = match locked_rev {
Some(rev) => db.contains(rev).then_some(rev),
None => reference.resolve(&db.repo).ok(),
};
if let Some(rev) = resolved_commit_hash {
return Ok((db, rev));
}
}
if into.exists() {
fs_err::remove_dir_all(into)?;
}
fs_err::create_dir_all(into)?;
let mut repo = GitRepository::init(into)?;
fetch(&mut repo, self.url.as_str(), reference, client)?;
let rev = match locked_rev {
Some(rev) => rev,
None => reference.resolve(&repo)?,
};
Ok((GitDatabase { repo }, rev))
}
#[allow(clippy::unused_self)]
pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase, GitError> {
let repo = GitRepository::open(db_path)?;
Ok(GitDatabase { repo })
}
pub fn url(&self) -> &Url {
&self.url
}
}
#[derive(Debug, Clone)]
pub struct CheckoutOptions {
pub update_submodules: bool,
}
impl Default for CheckoutOptions {
fn default() -> Self {
Self {
update_submodules: true,
}
}
}
pub(crate) struct GitDatabase {
repo: GitRepository,
}
impl GitDatabase {
pub(crate) fn copy_to(
&self,
rev: GitOid,
destination: &Path,
source_url: &Url,
options: &CheckoutOptions,
) -> Result<GitCheckout, GitError> {
let checkout = match GitRepository::open(destination)
.ok()
.map(|repo| GitCheckout::new(rev, repo))
.filter(GitCheckout::is_fresh)
{
Some(co) => co,
None => GitCheckout::clone_into(destination, self, rev, source_url, options)?,
};
Ok(checkout)
}
pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String, GitError> {
let output = Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("rev-parse")
.arg("--short")
.arg(revision.as_str())
.current_dir(&self.repo.path)
.output()?;
let mut result = String::from_utf8(output.stdout)?;
result.truncate(result.trim_end().len());
tracing::debug!("result of short id is {:?}", result);
Ok(result)
}
pub(crate) fn contains(&self, oid: GitOid) -> bool {
self.repo.rev_parse(&format!("{oid}^0")).is_ok()
}
}
pub(crate) struct GitRepository {
path: PathBuf,
}
impl GitRepository {
pub(crate) fn open(path: &Path) -> Result<GitRepository, GitError> {
let output = Command::new(GIT.as_ref().map_err(Clone::clone)?)
.args(["rev-parse", "--git-dir"])
.current_dir(path)
.output()?;
if !output.status.success() {
return Err(GitError::InvalidRepository(path.to_path_buf()));
}
Ok(GitRepository {
path: path.to_path_buf(),
})
}
fn init(path: &Path) -> Result<GitRepository, GitError> {
Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("init")
.current_dir(path)
.output()?;
Ok(GitRepository {
path: path.to_path_buf(),
})
}
fn rev_parse(&self, refname: &str) -> Result<GitOid, GitError> {
let result = Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("rev-parse")
.arg(refname)
.current_dir(&self.path)
.output()?;
let mut result = String::from_utf8(result.stdout)?;
result.truncate(result.trim_end().len());
result.parse().map_err(GitError::OidParse)
}
}
pub(crate) struct GitCheckout {
revision: GitOid,
repo: GitRepository,
}
impl GitCheckout {
fn new(revision: GitOid, repo: GitRepository) -> Self {
Self { revision, repo }
}
fn clone_into(
into: &Path,
database: &GitDatabase,
revision: GitOid,
source_url: &Url,
options: &CheckoutOptions,
) -> Result<Self, GitError> {
tracing::debug!("cloning into {:?} from {:?}", database.repo.path, into);
let dirname = into.parent().expect("into path must have a parent");
fs_err::create_dir_all(dirname)?;
if into.exists() {
fs_err::remove_dir_all(into)?;
}
let output = Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("clone")
.arg("--local")
.arg(dunce::simplified(&database.repo.path).display().to_string())
.arg(dunce::simplified(into).display().to_string())
.env("GIT_LFS_SKIP_SMUDGE", "1")
.output()?;
tracing::debug!("output after cloning {:?}", output);
let repo = GitRepository::open(into)?;
let checkout = GitCheckout::new(revision, repo);
checkout.reset(source_url, options)?;
Ok(checkout)
}
fn is_fresh(&self) -> bool {
match self.repo.rev_parse("HEAD") {
Ok(id) if id == self.revision => {
self.repo.path.join(CHECKOUT_READY_LOCK).exists()
}
_ => false,
}
}
fn reset(&self, source_url: &Url, options: &CheckoutOptions) -> Result<(), GitError> {
let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
let _ = fs_err::remove_file(&ok_file);
tracing::debug!("reset {} to {}", self.repo.path.display(), self.revision);
Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("reset")
.arg("--hard")
.arg(self.revision.as_str())
.current_dir(&self.repo.path)
.env("GIT_LFS_SKIP_SMUDGE", "1")
.output()?;
if options.update_submodules {
resolve_submodule_urls(&self.repo.path, source_url)?;
Command::new(GIT.as_ref().map_err(Clone::clone)?)
.args(["-c", "protocol.file.allow=always"])
.arg("submodule")
.arg("update")
.arg("--recursive")
.arg("--init")
.current_dir(&self.repo.path)
.env("GIT_LFS_SKIP_SMUDGE", "1")
.output()
.map(drop)?;
}
fs_err::File::create(ok_file)?;
Ok(())
}
}
pub(crate) fn fetch(
repo: &mut GitRepository,
remote_url: &str,
reference: &GitReference,
client: &ClientWithMiddleware,
) -> Result<(), GitError> {
let oid_to_fetch = match github_fast_path(repo, remote_url, reference, client) {
Ok(FastPathRev::UpToDate) => return Ok(()),
Ok(FastPathRev::NeedsFetch(rev)) => Some(rev),
Ok(FastPathRev::Indeterminate) => None,
Err(e) => {
tracing::debug!("failed to check github fast path {:?}", e);
None
}
};
let mut refspecs = Vec::new();
let mut tags = false;
let mut refspec_strategy = RefspecStrategy::All;
match reference {
GitReference::Branch(branch) => {
refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
}
GitReference::Tag(tag) => {
refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
}
GitReference::BranchOrTag(branch_or_tag) => {
refspecs.push(format!(
"+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
));
refspecs.push(format!(
"+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
));
refspec_strategy = RefspecStrategy::First;
}
GitReference::ShortCommit(branch_or_tag_or_commit)
| GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit) => {
if let Some(oid_to_fetch) =
oid_to_fetch.filter(|oid| is_short_hash_of(branch_or_tag_or_commit, *oid))
{
refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
} else {
refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
tags = true;
}
}
GitReference::DefaultBranch => {
refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
}
GitReference::NamedRef(rev) => {
refspecs.push(format!("+{rev}:{rev}"));
}
GitReference::FullCommit(rev) => {
if let Some(oid_to_fetch) = oid_to_fetch {
refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
} else {
refspecs.push(format!("+{rev}:refs/remotes/origin/HEAD"));
}
}
}
tracing::debug!(
"Performing a Git fetch for: {remote_url} with repo path {}",
repo.path.display()
);
let result = match refspec_strategy {
RefspecStrategy::All => fetch_with_cli(repo, remote_url, refspecs.as_slice(), tags),
RefspecStrategy::First => {
let mut errors = refspecs
.iter()
.map_while(|refspec| {
let fetch_result =
fetch_with_cli(repo, remote_url, std::slice::from_ref(refspec), tags);
match fetch_result {
Err(ref err) => {
tracing::debug!("failed to fetch refspec `{refspec}`: {err}");
Some(fetch_result)
}
Ok(()) => None,
}
})
.collect::<Vec<_>>();
if errors.len() == refspecs.len() {
if let Some(result) = errors.pop() {
result
} else {
Ok(())
}
} else {
Ok(())
}
}
};
tracing::debug!("fetched with cli {:?}", result);
result
}
fn fetch_with_cli(
repo: &mut GitRepository,
url: &str,
refspecs: &[String],
tags: bool,
) -> Result<(), GitError> {
let mut cmd = Command::new(GIT.as_ref().map_err(Clone::clone)?);
cmd.arg("fetch");
if tags {
cmd.arg("--tags");
}
cmd.arg("--force") .arg("--update-head-ok") .arg(url)
.args(refspecs)
.env_remove(GIT_DIR)
.current_dir(&repo.path);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8(output.stderr)?;
return Err(GitError::Fetch(url.to_string(), stderr));
}
tracing::debug!("git fetch output: {:?}", output);
Ok(())
}
enum FastPathRev {
UpToDate,
NeedsFetch(GitOid),
Indeterminate,
}
fn github_fast_path(
repo: &mut GitRepository,
url: &str,
reference: &GitReference,
client: &ClientWithMiddleware,
) -> Result<FastPathRev, GitError> {
let url = Url::parse(url)?;
if !is_github(&url) {
return Ok(FastPathRev::Indeterminate);
}
let local_object = reference.resolve(repo).ok();
let github_branch_name = match reference {
GitReference::Branch(branch) => branch,
GitReference::Tag(tag) => tag,
GitReference::BranchOrTag(branch_or_tag) => branch_or_tag,
GitReference::DefaultBranch => "HEAD",
GitReference::NamedRef(rev) => rev,
GitReference::FullCommit(rev)
| GitReference::ShortCommit(rev)
| GitReference::BranchOrTagOrCommit(rev) => {
if let Some(ref local_object) = local_object {
if is_short_hash_of(rev, *local_object) {
return Ok(FastPathRev::UpToDate);
}
}
rev
}
};
let mut pieces = url.path_segments().ok_or_else(|| {
GitError::GitUrlFormat(
url.as_str().to_string(),
"no path segments on url".to_string(),
)
})?;
let username = pieces.next().ok_or_else(|| {
GitError::GitUrlFormat(
url.as_str().to_string(),
"couldn't find username or organisation name".to_string(),
)
})?;
let repository = pieces.next().ok_or_else(|| {
GitError::GitUrlFormat(
url.as_str().to_string(),
"couldn't find repository name".to_string(),
)
})?;
if pieces.next().is_some() {
return Err(GitError::GitUrlFormat(
url.as_str().to_string(),
"too many segments in the url".to_string(),
));
}
let repository = repository.strip_suffix(".git").unwrap_or(repository);
let url = format!(
"https://api.github.com/repos/{username}/{repository}/commits/{github_branch_name}"
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async move {
tracing::debug!("Attempting GitHub fast path for: {url}");
let mut request = client.get(&url);
request = request.header("Accept", "application/vnd.github.3.sha");
request = request.header("User-Agent", "pixi");
if let Some(local_object) = local_object {
request = request.header("If-None-Match", local_object.to_string());
}
let response = request.send().await?;
response.error_for_status_ref()?;
let response_code = response.status();
if response_code == StatusCode::NOT_MODIFIED {
Ok(FastPathRev::UpToDate)
} else if response_code == StatusCode::OK {
let oid_to_fetch = response.text().await?.parse()?;
Ok(FastPathRev::NeedsFetch(oid_to_fetch))
} else {
Ok(FastPathRev::Indeterminate)
}
})
}
fn is_github(url: &Url) -> bool {
url.host_str() == Some("github.com")
}
fn is_short_hash_of(rev: &str, oid: GitOid) -> bool {
let long_hash = oid.to_string();
match long_hash.get(..rev.len()) {
Some(truncated_long_hash) => truncated_long_hash.eq_ignore_ascii_case(rev),
None => false,
}
}
pub fn resolve_relative_url(base: &Url, relative: &str) -> Result<String, GitError> {
let mut base = base.clone();
if !base.path().ends_with('/') {
base.set_path(&format!("{}/", base.path()));
}
let resolved = base.join(relative)?;
Ok(resolved.to_string())
}
fn resolve_submodule_urls(repo_path: &Path, source_url: &Url) -> Result<(), GitError> {
let gitmodules_path = repo_path.join(".gitmodules");
if !gitmodules_path.exists() {
return Ok(());
}
let output = Command::new(GIT.as_ref().map_err(Clone::clone)?)
.current_dir(repo_path)
.args([
"config",
"--file",
".gitmodules",
"--get-regexp",
r"submodule\..*\.url",
])
.output()?;
if !output.status.success() {
return Ok(());
}
let stdout = String::from_utf8(output.stdout)?;
for line in stdout.lines() {
let Some((key, submodule_url)) = line.split_once(' ') else {
continue;
};
if !submodule_url.starts_with("./") && !submodule_url.starts_with("../") {
continue;
}
let resolved = resolve_relative_url(source_url, submodule_url)?;
let output = Command::new(GIT.as_ref().map_err(Clone::clone)?)
.current_dir(repo_path)
.args(["config", key, &resolved])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8(output.stderr)?;
return Err(GitError::SubmoduleUrl(key.to_string(), stderr));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_relative_url() {
let base = Url::parse("https://github.com/owner/repo.git").unwrap();
assert_eq!(
resolve_relative_url(&base, "../sibling.git").unwrap(),
"https://github.com/owner/sibling.git"
);
assert_eq!(
resolve_relative_url(&base, "./child.git").unwrap(),
"https://github.com/owner/repo.git/child.git"
);
let file_base = Url::parse("file:///tmp/repos/main.git").unwrap();
assert_eq!(
resolve_relative_url(&file_base, "../sub.git").unwrap(),
"file:///tmp/repos/sub.git"
);
}
#[test]
fn test_resolve_submodule_urls_no_gitmodules() {
let tmp = tempfile::tempdir().unwrap();
let url = Url::parse("https://github.com/owner/repo.git").unwrap();
resolve_submodule_urls(tmp.path(), &url).unwrap();
}
#[test]
fn test_resolve_submodule_urls_rewrites_relative() {
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("repo");
Command::new("git")
.args(["init"])
.arg(&repo_path)
.output()
.unwrap();
let gitmodules = r#"
[submodule "sub-relative"]
path = sub-relative
url = ../sibling.git
[submodule "sub-absolute"]
path = sub-absolute
url = https://github.com/other/absolute.git
[submodule "sub-child"]
path = sub-child
url = ./child.git
"#;
std::fs::write(repo_path.join(".gitmodules"), gitmodules.trim_ascii_start()).unwrap();
let source_url = Url::from_file_path(&repo_path).unwrap();
resolve_submodule_urls(&repo_path, &source_url).unwrap();
let output = Command::new("git")
.current_dir(&repo_path)
.args(["config", "submodule.sub-relative.url"])
.output()
.unwrap();
let expected_sibling = Url::from_file_path(tmp.path().join("sibling.git")).unwrap();
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
expected_sibling.as_str()
);
let output = Command::new("git")
.current_dir(&repo_path)
.args(["config", "submodule.sub-child.url"])
.output()
.unwrap();
let expected_child = Url::from_file_path(repo_path.join("child.git")).unwrap();
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
expected_child.as_str()
);
let output = Command::new("git")
.current_dir(&repo_path)
.args(["config", "submodule.sub-absolute.url"])
.output()
.unwrap();
assert!(!output.status.success());
}
}