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 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#[derive(Debug, Clone, Copy, Default)]
64pub struct GitHttpSettings {
65 disable_ssl: bool,
66 offline: bool,
67}
68
69impl GitHttpSettings {
70 #[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 #[must_use]
79 pub fn with_offline(mut self, offline: bool) -> Self {
80 self.offline = offline;
81 self
82 }
83}
84
85#[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 pub fn insert(&self, reference: RepositoryReference, sha: GitOid) {
100 self.0.pin().insert(reference, sha);
101 }
102
103 fn get(&self, reference: &RepositoryReference) -> Option<GitOid> {
105 self.0.pin().get(reference).copied()
106 }
107
108 pub fn get_precise(&self, url: &GitUrl) -> Option<GitOid> {
110 if let Some(precise) = url.precise() {
112 return Some(precise);
113 }
114
115 let reference = RepositoryReference::from(url);
117 if let Some(precise) = self.get(&reference) {
118 return Some(precise);
119 }
120
121 None
122 }
123
124 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 let Some(precise) = self.get_precise(url) {
139 return Ok(Some(precise));
140 }
141
142 let Some(GitHubRepository { owner, repo }) = GitHubRepository::parse(url.repository())
144 else {
145 return Ok(None);
146 };
147
148 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 let reference = url
156 .reference()
157 .as_url_rev()
158 .unwrap_or_else(|| "HEAD".into());
159
160 let github_api_base_url = std::env::var(EnvVars::UV_GITHUB_FAST_PATH_URL)
161 .unwrap_or("https://api.github.com/repos".to_owned());
162 let github_api_url = format!("{github_api_base_url}/{owner}/{repo}/commits/{reference}");
163
164 debug!("Querying GitHub for commit at: {github_api_url}");
165 let mut request = client.get(&github_api_url);
166 request = request.header("Accept", "application/vnd.github.3.sha");
167 request = request.header(
168 "User-Agent",
169 format!("uv/{} (+https://github.com/astral-sh/uv)", version()),
170 );
171
172 let response = request.send().await?;
173 let status = response.status();
174 if !status.is_success() {
175 debug!(
178 "GitHub API request failed for: {github_api_url} ({})",
179 response.status()
180 );
181
182 if is_github_rate_limited(&response) {
183 GITHUB_RATE_LIMIT_STATUS.activate();
185 }
186
187 return Ok(None);
188 }
189
190 let precise = response.text().await?;
192 let precise =
193 GitOid::from_str(&precise).map_err(|err| GitResolverError::Git(err.into()))?;
194 let url = url
195 .clone()
196 .with_precise(precise)
197 .map_err(|error| GitResolverError::Git(error.into()))?;
198
199 self.insert(RepositoryReference::from(&url), precise);
202
203 Ok(Some(precise))
204 }
205
206 pub async fn fetch(
208 &self,
209 url: &GitUrl,
210 http_settings: GitHttpSettings,
211 cache: PathBuf,
212 reporter: Option<Arc<dyn Reporter>>,
213 ) -> Result<Fetch, GitResolverError> {
214 debug!("Fetching source distribution from Git: {url}");
215
216 let reference = RepositoryReference::from(url);
217
218 let url = {
221 if let Some(precise) = self.get(&reference) {
222 Cow::Owned(
223 url.clone()
224 .with_precise(precise)
225 .map_err(|error| GitResolverError::Git(error.into()))?,
226 )
227 } else {
228 Cow::Borrowed(url)
229 }
230 };
231
232 let lock_dir = cache.join("locks");
234 fs::create_dir_all(&lock_dir).await?;
235 let repository_url = url.repository().clone();
236 let _lock = LockedFile::acquire(
237 lock_dir.join(cache_digest(&repository_url)),
238 LockedFileMode::Exclusive,
239 &repository_url,
240 )
241 .await?;
242
243 let source = if let Some(reporter) = reporter {
245 GitSource::new(url.as_ref().clone(), cache, http_settings.offline)
246 .with_reporter(reporter)
247 } else {
248 GitSource::new(url.as_ref().clone(), cache, http_settings.offline)
249 };
250
251 let source = if http_settings.disable_ssl {
253 source.dangerous()
254 } else {
255 source
256 };
257
258 let fetch = tokio::task::spawn_blocking(move || source.fetch())
259 .await?
260 .map_err(GitResolverError::Git)?;
261
262 if let Some(precise) = fetch.git().precise() {
265 self.insert(reference, precise);
266 }
267
268 Ok(fetch)
269 }
270
271 pub fn precise(&self, url: GitUrl) -> Option<GitUrl> {
283 let precise = self.get_precise(&url)?;
284 url.with_precise(precise).ok()
285 }
286
287 pub fn same_ref(&self, a: &GitUrl, b: &GitUrl) -> bool {
289 let a_ref = RepositoryReference::from(a);
291
292 let b_ref = RepositoryReference::from(b);
294
295 if a_ref.url != b_ref.url {
297 return false;
298 }
299
300 if a_ref.reference == b_ref.reference {
302 return true;
303 }
304
305 let Some(a_precise) = a.precise().or_else(|| self.get(&a_ref)) else {
307 return false;
308 };
309
310 let Some(b_precise) = b.precise().or_else(|| self.get(&b_ref)) else {
311 return false;
312 };
313
314 a_precise == b_precise
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Hash)]
319pub struct ResolvedRepositoryReference {
320 pub reference: RepositoryReference,
323 pub sha: GitOid,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Hash)]
328pub struct RepositoryReference {
329 pub url: RepositoryUrl,
331 pub reference: GitReference,
333}
334
335impl From<&GitUrl> for RepositoryReference {
336 fn from(git: &GitUrl) -> Self {
337 Self {
338 url: git.repository().clone(),
339 reference: git.reference().clone(),
340 }
341 }
342}