Skip to main content

uv_git/
resolver.rs

1use std::borrow::Cow;
2use std::path::PathBuf;
3use std::str::FromStr;
4use std::sync::Arc;
5
6use fs_err::tokio as fs;
7use papaya::{HashMap, ResizeMode};
8use reqwest_middleware::ClientWithMiddleware;
9use tracing::debug;
10
11use uv_cache_key::{RepositoryUrl, cache_digest};
12use uv_fs::{LockedFile, LockedFileError, LockedFileMode};
13use uv_git_types::{GitHubRepository, GitOid, GitReference, GitUrl};
14use uv_static::EnvVars;
15use uv_version::version;
16
17use crate::{
18    Fetch, GitSource, Reporter,
19    rate_limit::{GITHUB_RATE_LIMIT_STATUS, is_github_rate_limited},
20};
21
22#[derive(Debug, thiserror::Error)]
23pub enum GitResolverError {
24    #[error(transparent)]
25    Io(#[from] std::io::Error),
26    #[error(transparent)]
27    LockedFile(#[from] LockedFileError),
28    #[error(transparent)]
29    Join(#[from] tokio::task::JoinError),
30    #[error("Git operation failed")]
31    Git(#[source] anyhow::Error),
32    #[error(transparent)]
33    Reqwest(#[from] reqwest::Error),
34    #[error(transparent)]
35    ReqwestMiddleware(#[from] reqwest_middleware::Error),
36}
37
38/// HTTP settings for fetching a Git repository.
39#[derive(Debug, Clone, Copy, Default)]
40pub struct GitHttpSettings {
41    disable_ssl: bool,
42    offline: bool,
43}
44
45impl GitHttpSettings {
46    /// Configure whether certificate verification should be disabled.
47    #[must_use]
48    pub fn with_disabled_ssl(mut self, disable_ssl: bool) -> Self {
49        self.disable_ssl = disable_ssl;
50        self
51    }
52
53    /// Configure whether network access should be disabled.
54    #[must_use]
55    pub fn with_offline(mut self, offline: bool) -> Self {
56        self.offline = offline;
57        self
58    }
59}
60
61/// A resolver for Git repositories.
62#[derive(Clone)]
63pub struct GitResolver(Arc<HashMap<RepositoryReference, GitOid>>);
64
65impl Default for GitResolver {
66    fn default() -> Self {
67        Self(Arc::new(
68            HashMap::builder().resize_mode(ResizeMode::Blocking).build(),
69        ))
70    }
71}
72
73impl GitResolver {
74    /// Inserts a new [`GitOid`] for the given [`RepositoryReference`].
75    pub fn insert(&self, reference: RepositoryReference, sha: GitOid) {
76        self.0.pin().insert(reference, sha);
77    }
78
79    /// Returns the [`GitOid`] for the given [`RepositoryReference`], if it exists.
80    fn get(&self, reference: &RepositoryReference) -> Option<GitOid> {
81        self.0.pin().get(reference).copied()
82    }
83
84    /// Return the [`GitOid`] for the given [`GitUrl`], if it is already known.
85    pub fn get_precise(&self, url: &GitUrl) -> Option<GitOid> {
86        // If the URL is already precise, return it.
87        if let Some(precise) = url.precise() {
88            return Some(precise);
89        }
90
91        // If we know the precise commit already, return it.
92        let reference = RepositoryReference::from(url);
93        if let Some(precise) = self.get(&reference) {
94            return Some(precise);
95        }
96
97        None
98    }
99
100    /// Resolve a Git URL to a specific commit without performing any Git operations.
101    ///
102    /// Returns a [`GitOid`] if the URL has already been resolved (i.e., is available in the cache),
103    /// or if it can be fetched via the GitHub API. Otherwise, returns `None`.
104    pub async fn github_fast_path(
105        &self,
106        url: &GitUrl,
107        client: &ClientWithMiddleware,
108    ) -> Result<Option<GitOid>, GitResolverError> {
109        if std::env::var_os(EnvVars::UV_NO_GITHUB_FAST_PATH).is_some() {
110            return Ok(None);
111        }
112
113        // If the URL is already precise or we know the precise commit, return it.
114        if let Some(precise) = self.get_precise(url) {
115            return Ok(Some(precise));
116        }
117
118        // If the URL is a GitHub URL, attempt to resolve it via the GitHub API.
119        let Some(GitHubRepository { owner, repo }) = GitHubRepository::parse(url.repository())
120        else {
121            return Ok(None);
122        };
123
124        // Check if we're rate-limited by GitHub, before determining the Git reference
125        if GITHUB_RATE_LIMIT_STATUS.is_active() {
126            debug!("Rate-limited by GitHub. Skipping GitHub fast path attempt for: {url}");
127            return Ok(None);
128        }
129
130        // Determine the Git reference.
131        let rev = url.reference().as_rev();
132
133        let github_api_base_url = std::env::var(EnvVars::UV_GITHUB_FAST_PATH_URL)
134            .unwrap_or("https://api.github.com/repos".to_owned());
135        let github_api_url = format!("{github_api_base_url}/{owner}/{repo}/commits/{rev}");
136
137        debug!("Querying GitHub for commit at: {github_api_url}");
138        let mut request = client.get(&github_api_url);
139        request = request.header("Accept", "application/vnd.github.3.sha");
140        request = request.header(
141            "User-Agent",
142            format!("uv/{} (+https://github.com/astral-sh/uv)", version()),
143        );
144
145        let response = request.send().await?;
146        let status = response.status();
147        if !status.is_success() {
148            // Returns a 404 if the repository does not exist, and a 422 if GitHub is unable to
149            // resolve the requested rev.
150            debug!(
151                "GitHub API request failed for: {github_api_url} ({})",
152                response.status()
153            );
154
155            if is_github_rate_limited(&response) {
156                // Mark that we are being rate-limited by GitHub
157                GITHUB_RATE_LIMIT_STATUS.activate();
158            }
159
160            return Ok(None);
161        }
162
163        // Parse the response as a Git SHA.
164        let precise = response.text().await?;
165        let precise =
166            GitOid::from_str(&precise).map_err(|err| GitResolverError::Git(err.into()))?;
167        let url = url
168            .clone()
169            .with_precise(precise)
170            .map_err(|error| GitResolverError::Git(error.into()))?;
171
172        // Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches
173        // resolve to the same precise commit.
174        self.insert(RepositoryReference::from(&url), precise);
175
176        Ok(Some(precise))
177    }
178
179    /// Fetch a remote Git repository.
180    pub async fn fetch(
181        &self,
182        url: &GitUrl,
183        http_settings: GitHttpSettings,
184        cache: PathBuf,
185        reporter: Option<Arc<dyn Reporter>>,
186    ) -> Result<Fetch, GitResolverError> {
187        debug!("Fetching source distribution from Git: {url}");
188
189        let reference = RepositoryReference::from(url);
190
191        // If we know the precise commit already, reuse it, to ensure that all fetches within a
192        // single process are consistent.
193        let url = {
194            if let Some(precise) = self.get(&reference) {
195                Cow::Owned(
196                    url.clone()
197                        .with_precise(precise)
198                        .map_err(|error| GitResolverError::Git(error.into()))?,
199                )
200            } else {
201                Cow::Borrowed(url)
202            }
203        };
204
205        // Avoid races between different processes, too.
206        let lock_dir = cache.join("locks");
207        fs::create_dir_all(&lock_dir).await?;
208        let repository_url = url.repository().clone();
209        let _lock = LockedFile::acquire(
210            lock_dir.join(cache_digest(&repository_url)),
211            LockedFileMode::Exclusive,
212            &repository_url,
213        )
214        .await?;
215
216        // Fetch the Git repository.
217        let source = if let Some(reporter) = reporter {
218            GitSource::new(url.as_ref().clone(), cache, http_settings.offline)
219                .with_reporter(reporter)
220        } else {
221            GitSource::new(url.as_ref().clone(), cache, http_settings.offline)
222        };
223
224        // If necessary, disable SSL.
225        let source = if http_settings.disable_ssl {
226            source.dangerous()
227        } else {
228            source
229        };
230
231        let fetch = tokio::task::spawn_blocking(move || source.fetch())
232            .await?
233            .map_err(GitResolverError::Git)?;
234
235        // Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches
236        // resolve to the same precise commit.
237        if let Some(precise) = fetch.git().precise() {
238            self.insert(reference, precise);
239        }
240
241        Ok(fetch)
242    }
243
244    /// Given a remote source distribution, return a precise variant, if possible.
245    ///
246    /// For example, given a Git dependency with a reference to a branch or tag, return a URL
247    /// with a precise reference to the current commit of that branch or tag.
248    ///
249    /// This method takes into account various normalizations that are independent of the Git
250    /// layer. For example: removing `#subdirectory=pkg_dir`-like fragments, and removing `git+`
251    /// prefix kinds.
252    ///
253    /// This method will only return precise URLs for URLs that have already been resolved via
254    /// [`resolve_precise`], and will return `None` for URLs that have not been resolved _or_
255    /// already have a precise reference.
256    pub fn precise(&self, url: GitUrl) -> Option<GitUrl> {
257        let reference = RepositoryReference::from(&url);
258        let precise = self.get(&reference)?;
259        url.with_precise(precise).ok()
260    }
261
262    /// Returns `true` if the two Git URLs refer to the same precise commit.
263    pub fn same_ref(&self, a: &GitUrl, b: &GitUrl) -> bool {
264        // Convert `a` to a repository URL.
265        let a_ref = RepositoryReference::from(a);
266
267        // Convert `b` to a repository URL.
268        let b_ref = RepositoryReference::from(b);
269
270        // The URLs must refer to the same repository.
271        if a_ref.url != b_ref.url {
272            return false;
273        }
274
275        // If the URLs have the same tag, they refer to the same commit.
276        if a_ref.reference == b_ref.reference {
277            return true;
278        }
279
280        // Otherwise, the URLs must resolve to the same precise commit.
281        let Some(a_precise) = a.precise().or_else(|| self.get(&a_ref)) else {
282            return false;
283        };
284
285        let Some(b_precise) = b.precise().or_else(|| self.get(&b_ref)) else {
286            return false;
287        };
288
289        a_precise == b_precise
290    }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Hash)]
294pub struct ResolvedRepositoryReference {
295    /// An abstract reference to a Git repository, including the URL and the commit (e.g., a branch,
296    /// tag, or revision).
297    pub reference: RepositoryReference,
298    /// The precise commit SHA of the reference.
299    pub sha: GitOid,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Hash)]
303pub struct RepositoryReference {
304    /// The URL of the Git repository, with any query parameters and fragments removed.
305    pub url: RepositoryUrl,
306    /// The reference to the commit to use, which could be a branch, tag, or revision.
307    pub reference: GitReference,
308}
309
310impl From<&GitUrl> for RepositoryReference {
311    fn from(git: &GitUrl) -> Self {
312        Self {
313            url: git.repository().clone(),
314            reference: git.reference().clone(),
315        }
316    }
317}