1pub use crate::github::GitHubRepository;
2pub use crate::oid::{GitOid, OidParseError};
3pub use crate::reference::GitReference;
4use std::cmp::Ordering;
5use std::sync::{Arc, LazyLock};
6
7use percent_encoding::percent_decode_str;
8use thiserror::Error;
9use uv_cache_key::RepositoryUrl;
10use uv_redacted::SafeUrl;
11use uv_static::EnvVars;
12
13mod github;
14mod oid;
15mod reference;
16
17static UV_GIT_LFS: LazyLock<GitLfs> = LazyLock::new(|| {
19 if std::env::var_os(EnvVars::UV_GIT_LFS)
21 .and_then(|v| v.to_str().map(str::to_lowercase))
22 .is_some_and(|v| matches!(v.as_str(), "y" | "yes" | "t" | "true" | "on" | "1"))
23 {
24 GitLfs::Enabled
25 } else {
26 GitLfs::Disabled
27 }
28});
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
32pub enum GitLfs {
33 #[default]
35 Disabled,
36 Enabled,
38}
39
40impl GitLfs {
41 pub fn from_env() -> Self {
43 *UV_GIT_LFS
44 }
45
46 pub fn enabled(self) -> bool {
48 matches!(self, Self::Enabled)
49 }
50}
51
52impl From<Option<bool>> for GitLfs {
53 fn from(value: Option<bool>) -> Self {
54 match value {
55 Some(true) => Self::Enabled,
56 Some(false) => Self::Disabled,
57 None => Self::from_env(),
58 }
59 }
60}
61
62impl From<bool> for GitLfs {
63 fn from(value: bool) -> Self {
64 if value { Self::Enabled } else { Self::Disabled }
65 }
66}
67
68impl std::fmt::Display for GitLfs {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::Enabled => write!(f, "enabled"),
72 Self::Disabled => write!(f, "disabled"),
73 }
74 }
75}
76
77#[derive(Debug, Error)]
78pub enum GitUrlParseError {
79 #[error(
80 "Unsupported Git URL scheme `{0}:` in `{1}` (expected one of `https:`, `ssh:`, or `file:`)"
81 )]
82 UnsupportedGitScheme(String, SafeUrl),
83 #[error(
84 "Ambiguous Git URL `{0}`: the path contains multiple `@` characters. If the Git revision contains `@`, percent-encode it as `%40`"
85 )]
86 AmbiguousRevision(SafeUrl),
87 #[error(
88 "Exact Git revision `{revision}` does not match precise commit `{precise}` for `{url}`"
89 )]
90 MismatchedRevision {
91 revision: String,
92 precise: GitOid,
93 url: Box<SafeUrl>,
94 },
95}
96
97#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct GitUrl(Arc<GitUrlInner>);
103
104#[derive(Debug, Clone)]
105struct GitUrlInner {
106 url: SafeUrl,
109 repository: RepositoryUrl,
111 reference: GitReference,
113 resolved: Option<GitOid>,
116 lfs: GitLfs,
118}
119
120impl GitUrl {
121 fn from_reference(
123 url: SafeUrl,
124 reference: GitReference,
125 lfs: GitLfs,
126 ) -> Result<Self, GitUrlParseError> {
127 Self::from_fields(url, reference, None, lfs)
128 }
129
130 pub fn from_commit(
132 url: SafeUrl,
133 reference: GitReference,
134 precise: GitOid,
135 lfs: GitLfs,
136 ) -> Result<Self, GitUrlParseError> {
137 Self::from_fields(url, reference, Some(precise), lfs)
138 }
139
140 pub fn from_fields(
142 url: SafeUrl,
143 reference: GitReference,
144 precise: Option<GitOid>,
145 lfs: GitLfs,
146 ) -> Result<Self, GitUrlParseError> {
147 match url.scheme() {
148 "http" | "https" | "ssh" | "file" => {}
149 unsupported => {
150 return Err(GitUrlParseError::UnsupportedGitScheme(
151 unsupported.to_string(),
152 url,
153 ));
154 }
155 }
156
157 let git = Self(Arc::new(GitUrlInner {
158 repository: RepositoryUrl::new(url.clone()),
159 url,
160 reference,
161 resolved: None,
162 lfs,
163 }));
164 match precise {
165 Some(precise) => git.with_precise(precise),
166 None => Ok(git),
167 }
168 }
169
170 pub fn with_precise(mut self, precise: GitOid) -> Result<Self, GitUrlParseError> {
172 match self.0.reference.as_commit() {
173 Some(commit) => {
174 if commit != precise {
175 return Err(GitUrlParseError::MismatchedRevision {
176 revision: commit.to_string(),
177 precise,
178 url: Box::new(self.0.url.clone()),
179 });
180 }
181 }
182 None => Arc::make_mut(&mut self.0).resolved = Some(precise),
183 }
184
185 Ok(self)
186 }
187
188 #[must_use]
190 pub fn with_reference(mut self, reference: GitReference) -> Self {
191 if self.0.reference != reference {
192 let inner = Arc::make_mut(&mut self.0);
193 inner.resolved = None;
194 inner.reference = reference;
195 }
196 self
197 }
198
199 pub fn url(&self) -> &SafeUrl {
201 &self.0.url
202 }
203
204 pub fn repository(&self) -> &RepositoryUrl {
206 &self.0.repository
207 }
208
209 pub fn reference(&self) -> &GitReference {
211 &self.0.reference
212 }
213
214 pub fn precise(&self) -> Option<GitOid> {
216 self.0.reference.as_commit().or(self.resolved_commit())
217 }
218
219 pub fn resolved_commit(&self) -> Option<GitOid> {
223 self.0.resolved
224 }
225
226 pub fn lfs(&self) -> GitLfs {
228 self.0.lfs
229 }
230
231 #[must_use]
233 pub fn with_lfs(mut self, lfs: GitLfs) -> Self {
234 if self.0.lfs != lfs {
235 Arc::make_mut(&mut self.0).lfs = lfs;
236 }
237 self
238 }
239}
240
241impl PartialEq for GitUrlInner {
242 fn eq(&self, other: &Self) -> bool {
243 self.repository == other.repository
244 && self.reference == other.reference
245 && self.resolved == other.resolved
246 && self.lfs == other.lfs
247 }
248}
249
250impl Eq for GitUrlInner {}
251
252impl PartialOrd for GitUrlInner {
253 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
254 Some(self.cmp(other))
255 }
256}
257
258impl Ord for GitUrlInner {
259 fn cmp(&self, other: &Self) -> Ordering {
260 self.repository
261 .cmp(&other.repository)
262 .then_with(|| self.reference.cmp(&other.reference))
263 .then_with(|| self.resolved.cmp(&other.resolved))
264 .then_with(|| self.lfs.cmp(&other.lfs))
265 }
266}
267
268impl std::hash::Hash for GitUrlInner {
269 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
270 self.repository.hash(state);
271 self.reference.hash(state);
272 self.resolved.hash(state);
273 self.lfs.hash(state);
274 }
275}
276
277impl TryFrom<SafeUrl> for GitUrl {
278 type Error = GitUrlParseError;
279
280 fn try_from(mut url: SafeUrl) -> Result<Self, Self::Error> {
282 url.set_fragment(None);
284 url.set_query(None);
285
286 if url.path().matches('@').nth(1).is_some() {
287 return Err(GitUrlParseError::AmbiguousRevision(url));
288 }
289
290 let mut reference = GitReference::DefaultBranch;
293 if let Some((prefix, suffix)) = url
294 .path()
295 .rsplit_once('@')
296 .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
297 {
298 let suffix = percent_decode_str(&suffix).decode_utf8_lossy().into_owned();
299 reference = GitReference::from_rev(suffix);
300 url.set_path(&prefix);
301 }
302
303 Self::from_reference(url, reference, GitLfs::from_env())
305 }
306}
307
308impl From<GitUrl> for SafeUrl {
309 fn from(git: GitUrl) -> Self {
310 let precise = git.precise();
311 let git = Arc::unwrap_or_clone(git.0);
312 let mut url = git.url;
313
314 if let Some(precise) = precise {
316 let path = format!("{}@{}", url.path(), precise);
317 url.set_path(&path);
318 } else if let Some(rev) = git.reference.as_url_rev() {
319 let path = format!("{}@{}", url.path(), rev);
320 url.set_path(&path);
321 }
322
323 url
324 }
325}
326
327impl std::fmt::Display for GitUrl {
328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 write!(f, "{}", self.url())
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn parse_percent_encoded_reference() -> Result<(), Box<dyn std::error::Error>> {
339 let url = SafeUrl::parse("https://example.com/pkg.git@dev%401%232")?;
340 let git = GitUrl::try_from(url)?;
341
342 assert_eq!(git.url().as_str(), "https://example.com/pkg.git");
343 assert_eq!(git.reference().as_str(), Some("dev@1#2"));
344
345 Ok(())
346 }
347
348 #[test]
349 fn parse_ssh_url_with_username_and_percent_encoded_reference()
350 -> Result<(), Box<dyn std::error::Error>> {
351 let url = SafeUrl::parse("ssh://git@github.com/example/example.git@abc%401.2.3")?;
352 let git = GitUrl::try_from(url)?;
353
354 assert_eq!(
355 git.url().as_str(),
356 "ssh://git@github.com/example/example.git"
357 );
358 assert_eq!(git.reference().as_str(), Some("abc@1.2.3"));
359
360 Ok(())
361 }
362
363 #[test]
364 fn reject_ambiguous_reference() -> Result<(), Box<dyn std::error::Error>> {
365 let url = SafeUrl::parse("https://example.com/pkg.git@dev@1.2.3")?;
366 let err = GitUrl::try_from(url).unwrap_err();
367
368 assert_eq!(
369 err.to_string(),
370 "Ambiguous Git URL `https://example.com/pkg.git@dev@1.2.3`: the path contains multiple `@` characters. If the Git revision contains `@`, percent-encode it as `%40`"
371 );
372
373 Ok(())
374 }
375
376 #[test]
377 fn full_commit_reference() -> Result<(), Box<dyn std::error::Error>> {
378 let commit = "0dacfd662c64cb4ceb16e6cf65a157a8b715b979".parse::<GitOid>()?;
379 let git = GitUrl::try_from(SafeUrl::parse(
380 "https://example.com/pkg.git@0DACFD662C64CB4CEB16E6CF65A157A8B715B979",
381 )?)?;
382
383 assert_eq!(git.reference(), &GitReference::Commit(commit));
384 assert_eq!(git.precise(), Some(commit));
385 assert_eq!(git, git.clone().with_precise(commit)?);
386
387 assert_eq!(
389 git.clone()
390 .with_reference(GitReference::Branch(commit.to_string()))
391 .precise(),
392 None
393 );
394 assert_eq!(
395 git.with_reference(GitReference::Tag(commit.to_string()))
396 .precise(),
397 None
398 );
399
400 Ok(())
401 }
402
403 #[test]
404 fn reject_mismatched_exact_revision() -> Result<(), Box<dyn std::error::Error>> {
405 let url = SafeUrl::parse("https://git:secret-token@example.com/pkg.git")?;
406 let requested_revision = "0dacfd662c64cb4ceb16e6cf65a157a8b715b979";
407 let precise = "b270df1a2fb5d012294e9aaf05e7e0bab1e6a389".parse::<GitOid>()?;
408
409 let error = GitUrl::from_commit(
410 url.clone(),
411 GitReference::from_rev(requested_revision.to_string()),
412 precise,
413 GitLfs::Disabled,
414 )
415 .expect_err("mismatched full revision must be rejected");
416 assert_eq!(
417 error.to_string(),
418 "Exact Git revision `0dacfd662c64cb4ceb16e6cf65a157a8b715b979` does not match precise commit `b270df1a2fb5d012294e9aaf05e7e0bab1e6a389` for `https://git:****@example.com/pkg.git`"
419 );
420
421 let git = GitUrl::from_reference(
422 url.clone(),
423 GitReference::from_rev(requested_revision.to_string()),
424 GitLfs::Disabled,
425 )?;
426 assert!(git.with_precise(precise).is_err());
427
428 let uppercase_revision = requested_revision.to_ascii_uppercase();
429 let expected = requested_revision.parse::<GitOid>()?;
430 assert!(
431 GitUrl::from_commit(
432 url.clone(),
433 GitReference::from_rev(uppercase_revision),
434 expected,
435 GitLfs::Disabled,
436 )
437 .is_ok()
438 );
439
440 assert!(
441 GitUrl::from_commit(
442 url.clone(),
443 GitReference::from_rev("0dacfd6".to_string()),
444 precise,
445 GitLfs::Disabled,
446 )
447 .is_ok()
448 );
449
450 assert!(
451 GitUrl::from_commit(
452 url,
453 GitReference::Branch(requested_revision.to_string()),
454 precise,
455 GitLfs::Disabled,
456 )
457 .is_ok()
458 );
459
460 Ok(())
461 }
462
463 #[test]
464 fn changing_reference_clears_precise_commit() -> Result<(), Box<dyn std::error::Error>> {
465 let url = SafeUrl::parse("https://example.com/pkg.git")?;
466 let precise = "0dacfd662c64cb4ceb16e6cf65a157a8b715b979".parse::<GitOid>()?;
467 let reference = GitReference::Branch("main".to_string());
468 let git = GitUrl::from_commit(url, reference.clone(), precise, GitLfs::Disabled)?;
469
470 assert_eq!(
471 git.clone().with_reference(reference).precise(),
472 Some(precise)
473 );
474 let commit = "b270df1a2fb5d012294e9aaf05e7e0bab1e6a389".parse::<GitOid>()?;
475 let git = git.with_reference(GitReference::from_rev(commit.to_string()));
476 assert_eq!(git.precise(), Some(commit));
477 assert_eq!(
478 git.with_reference(GitReference::Branch("main".to_string()))
479 .precise(),
480 None
481 );
482
483 Ok(())
484 }
485
486 #[test]
487 fn display_percent_encodes_reference() -> Result<(), Box<dyn std::error::Error>> {
488 let git = GitUrl::from_reference(
489 SafeUrl::parse("https://example.com/pkg.git")?,
490 GitReference::from_rev("refs/pull/493/head@1#2%".to_string()),
491 GitLfs::Disabled,
492 )?;
493 let url = SafeUrl::from(git);
494
495 assert_eq!(
496 url.as_str(),
497 "https://example.com/pkg.git@refs/pull/493/head%401%232%25"
498 );
499
500 let git = GitUrl::try_from(url)?;
501 assert_eq!(git.reference().as_str(), Some("refs/pull/493/head@1#2%"));
502
503 Ok(())
504 }
505}