use std::{
fmt::Display,
path::{Path, PathBuf},
process::Command,
str::FromStr,
sync::LazyLock,
};
use crate::LazyClient;
use reqwest::StatusCode;
use url::Url;
use crate::{
GitError,
sha::{GitOid, GitSha},
};
const CHECKOUT_READY_LOCK: &str = ".ok";
const CHECKOUT_LFS_DEGRADED: &str = "lfs-degraded";
pub const GIT_DIR: &str = "GIT_DIR";
pub const GIT_TERMINAL_PROMPT: &str = "GIT_TERMINAL_PROMPT";
pub const GIT_LFS_SKIP_SMUDGE: &str = "GIT_LFS_SKIP_SMUDGE";
#[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),
})
});
pub static GIT_LFS: LazyLock<Result<GitLfs, GitBinaryError>> = LazyLock::new(GitLfs::probe);
#[derive(Debug, Clone)]
pub struct GitLfs {
git: PathBuf,
}
impl GitLfs {
fn probe() -> Result<Self, GitBinaryError> {
let git = GIT.as_ref().map_err(Clone::clone)?.clone();
let ok = Command::new(&git)
.args(["lfs", "version"])
.env(GIT_TERMINAL_PROMPT, "0")
.output()
.is_ok_and(|o| o.status.success());
if ok {
Ok(Self { git })
} else {
Err(GitBinaryError::GitNotFound)
}
}
pub fn cmd(&self) -> Command {
let mut c = Command::new(&self.git);
c.arg("lfs").env(GIT_TERMINAL_PROMPT, "0");
c
}
}
fn git_output(cmd: &mut Command) -> Result<std::process::Output, GitError> {
let output = cmd.output()?;
if !output.status.success() {
let args = cmd
.get_args()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
return Err(GitError::Command(
args,
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(output)
}
fn lfs_skip_smudge_env(lfs: Option<bool>) -> &'static str {
if lfs == Some(true) { "0" } else { "1" }
}
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: &LazyClient,
options: &CheckoutOptions,
) -> 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 {
let ready = (options.lfs == Some(true))
.then(|| {
maybe_fetch_lfs(&mut db.repo, self.url.as_str(), rev, &options.lfs_filter)
})
.flatten();
return Ok((db.with_lfs_ready(ready), 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).map_err(|err| {
let mut repository = self.url.clone();
let _ = repository.set_password(None);
let _ = repository.set_username("");
GitError::ReferenceNotFound {
reference: reference.as_rev().to_string(),
repository: repository.to_string(),
source: Box::new(err),
}
})?,
};
let ready = (options.lfs == Some(true))
.then(|| maybe_fetch_lfs(&mut repo, self.url.as_str(), rev, &options.lfs_filter))
.flatten();
Ok((
GitDatabase {
repo,
lfs_ready: None,
}
.with_lfs_ready(ready),
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,
lfs_ready: None,
})
}
pub fn url(&self) -> &Url {
&self.url
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct LfsFilter {
pub include: Option<String>,
pub exclude: Option<String>,
}
impl LfsFilter {
pub fn is_empty(&self) -> bool {
self.include.is_none() && self.exclude.is_none()
}
fn configure_git(&self, command: &mut Command) {
if let Some(include) = &self.include {
command.arg("-c").arg(format!("lfs.fetchinclude={include}"));
}
if let Some(exclude) = &self.exclude {
command.arg("-c").arg(format!("lfs.fetchexclude={exclude}"));
}
}
fn configure_lfs_fetch(&self, command: &mut Command) {
if let Some(include) = &self.include {
command.arg(format!("--include={include}"));
}
if let Some(exclude) = &self.exclude {
command.arg(format!("--exclude={exclude}"));
}
}
}
#[derive(Debug, Clone)]
pub struct CheckoutOptions {
pub update_submodules: bool,
pub lfs: Option<bool>,
pub lfs_filter: LfsFilter,
}
impl Default for CheckoutOptions {
fn default() -> Self {
Self {
update_submodules: true,
lfs: Some(false),
lfs_filter: LfsFilter::default(),
}
}
}
pub(crate) struct GitDatabase {
repo: GitRepository,
lfs_ready: Option<bool>,
}
impl GitDatabase {
pub(crate) fn lfs_ready(&self) -> Option<bool> {
self.lfs_ready
}
#[must_use]
pub(crate) fn with_lfs_ready(mut self, value: Option<bool>) -> Self {
self.lfs_ready = value;
self
}
pub(crate) fn contains_lfs_artifacts(&self, revision: GitOid, filter: &LfsFilter) -> bool {
filter.is_empty() && self.repo.lfs_fsck_objects(revision)
}
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(|checkout| checkout.is_fresh(GIT_LFS.is_err()))
{
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 = git_output(
Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("rev-parse")
.arg("--short")
.arg(revision.as_str())
.current_dir(&self.repo.path),
)?;
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> {
git_output(
Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("init")
.current_dir(path),
)?;
Ok(GitRepository {
path: path.to_path_buf(),
})
}
fn rev_parse(&self, refname: &str) -> Result<GitOid, GitError> {
let result = git_output(
Command::new(GIT.as_ref().map_err(Clone::clone)?)
.arg("rev-parse")
.arg(refname)
.current_dir(&self.path),
)?;
let mut result = String::from_utf8(result.stdout)?;
result.truncate(result.trim_end().len());
result.parse().map_err(GitError::OidParse)
}
fn lfs_fsck_objects(&self, revision: GitOid) -> bool {
let Ok(lfs) = GIT_LFS.as_ref() else {
return false;
};
let output = lfs
.cmd()
.arg("fsck")
.arg("--objects")
.arg(revision.as_str())
.env_remove(GIT_DIR)
.current_dir(&self.path)
.output();
match output {
Ok(out) if out.status.success() => true,
Ok(out) => {
tracing::warn!(
"`git lfs fsck` reported problems for {revision} in {}: {}",
self.path.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
false
}
Err(err) => {
tracing::warn!(
"failed to run `git lfs fsck` for {revision} in {}: {err}",
self.path.display()
);
false
}
}
}
}
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 mut clone_cmd = Command::new(GIT.as_ref().map_err(Clone::clone)?);
if options.lfs == Some(true) {
options.lfs_filter.configure_git(&mut clone_cmd);
}
clone_cmd
.arg("clone")
.arg("--local")
.arg(dunce::simplified(&database.repo.path).display().to_string())
.arg(dunce::simplified(into).display().to_string());
clone_cmd.env(GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge_env(options.lfs));
let output = git_output(&mut clone_cmd)?;
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, accept_lfs_degraded: bool) -> bool {
match self.repo.rev_parse("HEAD") {
Ok(id) if id == self.revision => {
match fs_err::read_to_string(self.repo.path.join(CHECKOUT_READY_LOCK)) {
Ok(contents) => contents != CHECKOUT_LFS_DEGRADED || accept_lfs_degraded,
Err(_) => false,
}
}
_ => 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);
let skip_smudge = lfs_skip_smudge_env(options.lfs);
let mut reset_cmd = Command::new(GIT.as_ref().map_err(Clone::clone)?);
if options.lfs == Some(true) && GIT_LFS.is_ok() {
options.lfs_filter.configure_git(&mut reset_cmd);
reset_cmd
.arg("-c")
.arg("filter.lfs.smudge=git-lfs smudge -- %f")
.arg("-c")
.arg("filter.lfs.process=git-lfs filter-process")
.arg("-c")
.arg("filter.lfs.required=true");
}
reset_cmd
.arg("reset")
.arg("--hard")
.arg(self.revision.as_str())
.current_dir(&self.repo.path)
.env(GIT_LFS_SKIP_SMUDGE, skip_smudge);
git_output(&mut reset_cmd)?;
if options.update_submodules {
resolve_submodule_urls(&self.repo.path, source_url)?;
let mut submodule_cmd = Command::new(GIT.as_ref().map_err(Clone::clone)?);
if options.lfs == Some(true) {
options.lfs_filter.configure_git(&mut submodule_cmd);
}
submodule_cmd
.args(["-c", "protocol.file.allow=always"])
.arg("submodule")
.arg("update")
.arg("--recursive")
.arg("--init")
.current_dir(&self.repo.path);
submodule_cmd.env(GIT_LFS_SKIP_SMUDGE, skip_smudge);
git_output(&mut submodule_cmd)?;
}
if options.lfs == Some(true) && GIT_LFS.is_err() {
fs_err::write(ok_file, CHECKOUT_LFS_DEGRADED)?;
} else {
fs_err::File::create(ok_file)?;
}
Ok(())
}
}
pub(crate) fn fetch(
repo: &mut GitRepository,
remote_url: &str,
reference: &GitReference,
client: &LazyClient,
) -> 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 maybe_fetch_lfs(
repo: &mut GitRepository,
url: &str,
revision: GitOid,
filter: &LfsFilter,
) -> Option<bool> {
let lfs = if let Ok(lfs) = GIT_LFS.as_ref() {
lfs
} else {
tracing::warn!(
"`git-lfs` is not installed; skipping LFS fetch for {url}. \
Install git-lfs to download LFS-tracked files."
);
return Some(false);
};
match fetch_lfs(lfs, repo, url, revision, filter) {
Ok(fsck_ok) => Some(fsck_ok),
Err(err) => {
tracing::warn!("failed to fetch LFS objects for {url} at {revision}: {err}");
Some(false)
}
}
}
fn fetch_lfs(
lfs: &GitLfs,
repo: &mut GitRepository,
url: &str,
revision: GitOid,
filter: &LfsFilter,
) -> Result<bool, GitError> {
let remote = lfs_remote_url(url);
tracing::debug!("fetching LFS objects for {remote} at {revision}");
let mut command = lfs.cmd();
command.arg("fetch");
filter.configure_lfs_fetch(&mut command);
let output = command
.arg(&*remote)
.arg(revision.as_str())
.env_remove(GIT_DIR)
.env_remove(GIT_LFS_SKIP_SMUDGE)
.current_dir(&repo.path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8(output.stderr)?;
return Err(GitError::LfsFetch(remote.into_owned(), stderr));
}
tracing::debug!("git lfs fetch output: {:?}", output);
Ok(!filter.is_empty() || repo.lfs_fsck_objects(revision))
}
fn lfs_remote_url(url: &str) -> std::borrow::Cow<'_, str> {
if let Ok(parsed) = Url::parse(url)
&& parsed.scheme().len() == 1
&& parsed.scheme().chars().all(|c| c.is_ascii_alphabetic())
&& let Ok(file_url) = Url::from_file_path(Path::new(url))
{
return std::borrow::Cow::Owned(file_url.to_string());
}
std::borrow::Cow::Borrowed(url)
}
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)
.env(GIT_TERMINAL_PROMPT, "0")
.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: &LazyClient,
) -> 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
&& 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
.client()
.get(&url)
.header("Accept", "application/vnd.github.3.sha");
if let Some(local_object) = local_object {
request = request.header("If-None-Match", local_object.to_string());
}
let mut request = request.build()?;
if !request.headers().contains_key("User-Agent") {
request
.headers_mut()
.insert("User-Agent", "rattler".parse().unwrap());
}
let response = client.client().execute(request).await?;
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 {
tracing::debug!("GitHub fast path returned {response_code}, falling back to git fetch");
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_lfs_remote_url() {
if cfg!(windows) {
assert_eq!(
lfs_remote_url("D:/a/work/lfs_repo"),
"file:///D:/a/work/lfs_repo"
);
} else {
assert_eq!(lfs_remote_url("D:/a/work/lfs_repo"), "D:/a/work/lfs_repo");
}
assert_eq!(
lfs_remote_url("file:///repos/sample"),
"file:///repos/sample"
);
assert_eq!(
lfs_remote_url("https://github.com/owner/repo.git"),
"https://github.com/owner/repo.git"
);
assert_eq!(
lfs_remote_url("ssh://git@github.com/owner/repo.git"),
"ssh://git@github.com/owner/repo.git"
);
}
#[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());
}
}