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