use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::str::{self};
use std::sync::LazyLock;
use anyhow::{Context, Result};
use cargo_util::{ProcessBuilder, paths};
use tracing::{debug, warn};
use url::Url;
use uv_fs::Simplified;
use uv_git_types::{GitOid, GitReference};
use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars;
const CHECKOUT_READY_LOCK: &str = ".ok";
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("Git executable not found. Ensure that Git is installed and available.")]
GitNotFound,
#[error(transparent)]
Other(#[from] which::Error),
#[error(
"Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
)]
TransportNotAllowed,
}
pub static GIT: LazyLock<Result<PathBuf, GitError>> = LazyLock::new(|| {
which::which("git").map_err(|err| match err {
which::Error::CannotFindBinaryPath => GitError::GitNotFound,
err => GitError::Other(err),
})
});
enum RefspecStrategy {
All,
First,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum ReferenceOrOid<'reference> {
Reference(&'reference GitReference),
Oid(GitOid),
}
impl ReferenceOrOid<'_> {
fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
let refkind = self.kind_str();
let result = match self {
Self::Reference(GitReference::Tag(s)) => {
repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
}
Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
Self::Reference(GitReference::BranchOrTag(s)) => repo
.rev_parse(&format!("origin/{s}^0"))
.or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
Self::Reference(GitReference::BranchOrTagOrCommit(s)) => repo
.rev_parse(&format!("origin/{s}^0"))
.or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
.or_else(|_| repo.rev_parse(&format!("{s}^0"))),
Self::Reference(GitReference::DefaultBranch) => {
repo.rev_parse("refs/remotes/origin/HEAD")
}
Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
Self::Oid(s) => repo.rev_parse(&format!("{s}^0")),
};
result.with_context(|| anyhow::format_err!("failed to find {refkind} `{self}`"))
}
fn kind_str(&self) -> &str {
match self {
Self::Reference(reference) => reference.kind_str(),
Self::Oid(_) => "commit",
}
}
fn as_rev(&self) -> &str {
match self {
Self::Reference(r) => r.as_rev(),
Self::Oid(rev) => rev.as_str(),
}
}
}
impl Display for ReferenceOrOid<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Reference(reference) => write!(f, "{reference}"),
Self::Oid(oid) => write!(f, "{oid}"),
}
}
}
#[derive(PartialEq, Clone, Debug)]
pub(crate) struct GitRemote {
url: DisplaySafeUrl,
}
pub(crate) struct GitDatabase {
repo: GitRepository,
}
pub(crate) struct GitCheckout {
revision: GitOid,
repo: GitRepository,
}
pub(crate) struct GitRepository {
path: PathBuf,
}
impl GitRepository {
pub(crate) fn open(path: &Path) -> Result<Self> {
ProcessBuilder::new(GIT.as_ref()?)
.arg("rev-parse")
.cwd(path)
.exec_with_output()?;
Ok(Self {
path: path.to_path_buf(),
})
}
fn init(path: &Path) -> Result<Self> {
ProcessBuilder::new(GIT.as_ref()?)
.arg("init")
.cwd(path)
.exec_with_output()?;
Ok(Self {
path: path.to_path_buf(),
})
}
fn rev_parse(&self, refname: &str) -> Result<GitOid> {
let result = ProcessBuilder::new(GIT.as_ref()?)
.arg("rev-parse")
.arg(refname)
.cwd(&self.path)
.exec_with_output()?;
let mut result = String::from_utf8(result.stdout)?;
result.truncate(result.trim_end().len());
Ok(result.parse()?)
}
}
impl GitRemote {
pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
Self { url: url.clone() }
}
pub(crate) fn url(&self) -> &DisplaySafeUrl {
&self.url
}
pub(crate) fn checkout(
&self,
into: &Path,
db: Option<GitDatabase>,
reference: &GitReference,
locked_rev: Option<GitOid>,
disable_ssl: bool,
offline: bool,
) -> Result<(GitDatabase, GitOid)> {
let reference = locked_rev
.map(ReferenceOrOid::Oid)
.unwrap_or(ReferenceOrOid::Reference(reference));
let enable_lfs_fetch = std::env::var(EnvVars::UV_GIT_LFS).is_ok();
if let Some(mut db) = db {
fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
.with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
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 {
if enable_lfs_fetch {
fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
.with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
}
return Ok((db, rev));
}
}
match fs_err::remove_dir_all(into) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
fs_err::create_dir_all(into)?;
let mut repo = GitRepository::init(into)?;
fetch(&mut repo, &self.url, reference, disable_ssl, offline)
.with_context(|| format!("failed to clone into: {}", into.user_display()))?;
let rev = match locked_rev {
Some(rev) => rev,
None => reference.resolve(&repo)?,
};
if enable_lfs_fetch {
fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
.with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
}
Ok((GitDatabase { repo }, rev))
}
#[allow(clippy::unused_self)]
pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
let repo = GitRepository::open(db_path)?;
Ok(GitDatabase { repo })
}
}
impl GitDatabase {
pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
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)?,
};
Ok(checkout)
}
pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
let output = ProcessBuilder::new(GIT.as_ref()?)
.arg("rev-parse")
.arg("--short")
.arg(revision.as_str())
.cwd(&self.repo.path)
.exec_with_output()?;
let mut result = String::from_utf8(output.stdout)?;
result.truncate(result.trim_end().len());
Ok(result)
}
pub(crate) fn contains(&self, oid: GitOid) -> bool {
self.repo.rev_parse(&format!("{oid}^0")).is_ok()
}
}
impl GitCheckout {
fn new(revision: GitOid, repo: GitRepository) -> Self {
Self { revision, repo }
}
fn clone_into(into: &Path, database: &GitDatabase, revision: GitOid) -> Result<Self> {
let dirname = into.parent().unwrap();
fs_err::create_dir_all(dirname)?;
match fs_err::remove_dir_all(into) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
let res = ProcessBuilder::new(GIT.as_ref()?)
.arg("clone")
.arg("--local")
.arg(database.repo.path.simplified_display().to_string())
.arg(into.simplified_display().to_string())
.exec_with_output();
if let Err(e) = res {
debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
ProcessBuilder::new(GIT.as_ref()?)
.arg("clone")
.arg("--no-hardlinks")
.arg(database.repo.path.simplified_display().to_string())
.arg(into.simplified_display().to_string())
.exec_with_output()?;
}
let repo = GitRepository::open(into)?;
let checkout = Self::new(revision, repo);
checkout.reset()?;
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) -> Result<()> {
let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
let _ = paths::remove_file(&ok_file);
debug!("Reset {} to {}", self.repo.path.display(), self.revision);
ProcessBuilder::new(GIT.as_ref()?)
.arg("reset")
.arg("--hard")
.arg(self.revision.as_str())
.cwd(&self.repo.path)
.exec_with_output()?;
ProcessBuilder::new(GIT.as_ref()?)
.arg("submodule")
.arg("update")
.arg("--recursive")
.arg("--init")
.cwd(&self.repo.path)
.exec_with_output()
.map(drop)?;
paths::create(ok_file)?;
Ok(())
}
}
fn fetch(
repo: &mut GitRepository,
remote_url: &Url,
reference: ReferenceOrOid<'_>,
disable_ssl: bool,
offline: bool,
) -> Result<()> {
let oid_to_fetch = if let ReferenceOrOid::Oid(rev) = reference {
let local_object = reference.resolve(repo).ok();
if let Some(local_object) = local_object {
if rev == local_object {
return Ok(());
}
}
Some(rev)
} else {
None
};
let mut refspecs = Vec::new();
let mut tags = false;
let mut refspec_strategy = RefspecStrategy::All;
match reference {
ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
}
ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
}
ReferenceOrOid::Reference(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;
}
ReferenceOrOid::Reference(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;
}
}
ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
}
ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
refspecs.push(format!("+{rev}:{rev}"));
}
ReferenceOrOid::Oid(rev) => {
refspecs.push(format!("+{rev}:refs/commit/{rev}"));
}
}
debug!("Performing a Git fetch for: {remote_url}");
let result = match refspec_strategy {
RefspecStrategy::All => fetch_with_cli(
repo,
remote_url,
refspecs.as_slice(),
tags,
disable_ssl,
offline,
),
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,
disable_ssl,
offline,
);
match fetch_result {
Err(ref err) => {
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(())
}
}
};
match reference {
ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
_ => result.with_context(|| {
format!(
"failed to fetch {} `{}`",
reference.kind_str(),
reference.as_rev()
)
}),
}
}
fn fetch_with_cli(
repo: &mut GitRepository,
url: &Url,
refspecs: &[String],
tags: bool,
disable_ssl: bool,
offline: bool,
) -> Result<()> {
let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
cmd.arg("fetch");
if tags {
cmd.arg("--tags");
}
if disable_ssl {
debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
}
if offline {
debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
}
cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
.args(refspecs)
.env_remove(EnvVars::GIT_DIR)
.env_remove(EnvVars::GIT_WORK_TREE)
.env_remove(EnvVars::GIT_INDEX_FILE)
.env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
.env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
.cwd(&repo.path);
cmd.exec_with_output().map_err(|err| {
let msg = err.to_string();
if msg.contains("transport '") && msg.contains("' not allowed") && offline {
return GitError::TransportNotAllowed.into();
}
err
})?;
Ok(())
}
static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
cmd.arg("lfs");
cmd.clone().arg("version").exec_with_output()?;
Ok(cmd)
});
fn fetch_lfs(
repo: &mut GitRepository,
url: &Url,
revision: &GitOid,
disable_ssl: bool,
) -> Result<()> {
let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
debug!("Fetching Git LFS objects");
lfs.clone()
} else {
warn!("Git LFS is not available, skipping LFS fetch");
return Ok(());
};
if disable_ssl {
debug!("Disabling SSL verification for Git LFS");
cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
}
cmd.arg("fetch")
.arg(url.as_str())
.arg(revision.as_str())
.env_remove(EnvVars::GIT_DIR)
.env_remove(EnvVars::GIT_WORK_TREE)
.env_remove(EnvVars::GIT_INDEX_FILE)
.env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
.env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
.cwd(&repo.path);
cmd.exec_with_output()?;
Ok(())
}
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,
}
}