1pub use crate::github::GitHubRepository;
2pub use crate::oid::{GitOid, OidParseError};
3pub use crate::reference::GitReference;
4use std::cmp::Ordering;
5use std::sync::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)]
99pub struct GitUrl {
100 url: SafeUrl,
103 repository: RepositoryUrl,
105 reference: GitReference,
107 resolved: Option<GitOid>,
110 lfs: GitLfs,
112}
113
114impl GitUrl {
115 fn from_reference(
117 url: SafeUrl,
118 reference: GitReference,
119 lfs: GitLfs,
120 ) -> Result<Self, GitUrlParseError> {
121 Self::from_fields(url, reference, None, lfs)
122 }
123
124 pub fn from_commit(
126 url: SafeUrl,
127 reference: GitReference,
128 precise: GitOid,
129 lfs: GitLfs,
130 ) -> Result<Self, GitUrlParseError> {
131 Self::from_fields(url, reference, Some(precise), lfs)
132 }
133
134 pub fn from_fields(
136 url: SafeUrl,
137 reference: GitReference,
138 precise: Option<GitOid>,
139 lfs: GitLfs,
140 ) -> Result<Self, GitUrlParseError> {
141 match url.scheme() {
142 "http" | "https" | "ssh" | "file" => {}
143 unsupported => {
144 return Err(GitUrlParseError::UnsupportedGitScheme(
145 unsupported.to_string(),
146 url,
147 ));
148 }
149 }
150
151 let git = Self {
152 repository: RepositoryUrl::new(url.clone()),
153 url,
154 reference,
155 resolved: None,
156 lfs,
157 };
158 match precise {
159 Some(precise) => git.with_precise(precise),
160 None => Ok(git),
161 }
162 }
163
164 pub fn with_precise(mut self, precise: GitOid) -> Result<Self, GitUrlParseError> {
166 match self.reference.as_commit() {
167 Some(commit) => {
168 if commit != precise {
169 return Err(GitUrlParseError::MismatchedRevision {
170 revision: commit.to_string(),
171 precise,
172 url: Box::new(self.url.clone()),
173 });
174 }
175 }
176 None => self.resolved = Some(precise),
177 }
178
179 Ok(self)
180 }
181
182 #[must_use]
184 pub fn with_reference(mut self, reference: GitReference) -> Self {
185 if self.reference != reference {
186 self.resolved = None;
187 self.reference = reference;
188 }
189 self
190 }
191
192 pub fn url(&self) -> &SafeUrl {
194 &self.url
195 }
196
197 pub fn repository(&self) -> &RepositoryUrl {
199 &self.repository
200 }
201
202 pub fn reference(&self) -> &GitReference {
204 &self.reference
205 }
206
207 pub fn precise(&self) -> Option<GitOid> {
209 self.reference.as_commit().or(self.resolved_commit())
210 }
211
212 pub fn resolved_commit(&self) -> Option<GitOid> {
216 self.resolved
217 }
218
219 pub fn lfs(&self) -> GitLfs {
221 self.lfs
222 }
223
224 #[must_use]
226 pub fn with_lfs(mut self, lfs: GitLfs) -> Self {
227 self.lfs = lfs;
228 self
229 }
230}
231
232impl PartialEq for GitUrl {
233 fn eq(&self, other: &Self) -> bool {
234 self.repository == other.repository
235 && self.reference == other.reference
236 && self.resolved == other.resolved
237 && self.lfs == other.lfs
238 }
239}
240
241impl Eq for GitUrl {}
242
243impl PartialOrd for GitUrl {
244 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
245 Some(self.cmp(other))
246 }
247}
248
249impl Ord for GitUrl {
250 fn cmp(&self, other: &Self) -> Ordering {
251 self.repository
252 .cmp(&other.repository)
253 .then_with(|| self.reference.cmp(&other.reference))
254 .then_with(|| self.resolved.cmp(&other.resolved))
255 .then_with(|| self.lfs.cmp(&other.lfs))
256 }
257}
258
259impl std::hash::Hash for GitUrl {
260 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
261 self.repository.hash(state);
262 self.reference.hash(state);
263 self.resolved.hash(state);
264 self.lfs.hash(state);
265 }
266}
267
268impl TryFrom<SafeUrl> for GitUrl {
269 type Error = GitUrlParseError;
270
271 fn try_from(mut url: SafeUrl) -> Result<Self, Self::Error> {
273 url.set_fragment(None);
275 url.set_query(None);
276
277 if url.path().matches('@').nth(1).is_some() {
278 return Err(GitUrlParseError::AmbiguousRevision(url));
279 }
280
281 let mut reference = GitReference::DefaultBranch;
284 if let Some((prefix, suffix)) = url
285 .path()
286 .rsplit_once('@')
287 .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
288 {
289 let suffix = percent_decode_str(&suffix).decode_utf8_lossy().into_owned();
290 reference = GitReference::from_rev(suffix);
291 url.set_path(&prefix);
292 }
293
294 Self::from_reference(url, reference, GitLfs::from_env())
296 }
297}
298
299impl From<GitUrl> for SafeUrl {
300 fn from(git: GitUrl) -> Self {
301 let precise = git.precise();
302 let mut url = git.url;
303
304 if let Some(precise) = precise {
306 let path = format!("{}@{}", url.path(), precise);
307 url.set_path(&path);
308 } else if let Some(rev) = git.reference.as_url_rev() {
309 let path = format!("{}@{}", url.path(), rev);
310 url.set_path(&path);
311 }
312
313 url
314 }
315}
316
317impl std::fmt::Display for GitUrl {
318 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319 write!(f, "{}", self.url)
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn parse_percent_encoded_reference() -> Result<(), Box<dyn std::error::Error>> {
329 let url = SafeUrl::parse("https://example.com/pkg.git@dev%401%232")?;
330 let git = GitUrl::try_from(url)?;
331
332 assert_eq!(git.url().as_str(), "https://example.com/pkg.git");
333 assert_eq!(git.reference().as_str(), Some("dev@1#2"));
334
335 Ok(())
336 }
337
338 #[test]
339 fn parse_ssh_url_with_username_and_percent_encoded_reference()
340 -> Result<(), Box<dyn std::error::Error>> {
341 let url = SafeUrl::parse("ssh://git@github.com/example/example.git@abc%401.2.3")?;
342 let git = GitUrl::try_from(url)?;
343
344 assert_eq!(
345 git.url().as_str(),
346 "ssh://git@github.com/example/example.git"
347 );
348 assert_eq!(git.reference().as_str(), Some("abc@1.2.3"));
349
350 Ok(())
351 }
352
353 #[test]
354 fn reject_ambiguous_reference() -> Result<(), Box<dyn std::error::Error>> {
355 let url = SafeUrl::parse("https://example.com/pkg.git@dev@1.2.3")?;
356 let err = GitUrl::try_from(url).unwrap_err();
357
358 assert_eq!(
359 err.to_string(),
360 "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`"
361 );
362
363 Ok(())
364 }
365
366 #[test]
367 fn full_commit_reference() -> Result<(), Box<dyn std::error::Error>> {
368 let commit = "0dacfd662c64cb4ceb16e6cf65a157a8b715b979".parse::<GitOid>()?;
369 let git = GitUrl::try_from(SafeUrl::parse(
370 "https://example.com/pkg.git@0DACFD662C64CB4CEB16E6CF65A157A8B715B979",
371 )?)?;
372
373 assert_eq!(git.reference(), &GitReference::Commit(commit));
374 assert_eq!(git.precise(), Some(commit));
375 assert_eq!(git, git.clone().with_precise(commit)?);
376
377 assert_eq!(
379 git.clone()
380 .with_reference(GitReference::Branch(commit.to_string()))
381 .precise(),
382 None
383 );
384 assert_eq!(
385 git.with_reference(GitReference::Tag(commit.to_string()))
386 .precise(),
387 None
388 );
389
390 Ok(())
391 }
392
393 #[test]
394 fn reject_mismatched_exact_revision() -> Result<(), Box<dyn std::error::Error>> {
395 let url = SafeUrl::parse("https://git:secret-token@example.com/pkg.git")?;
396 let requested_revision = "0dacfd662c64cb4ceb16e6cf65a157a8b715b979";
397 let precise = "b270df1a2fb5d012294e9aaf05e7e0bab1e6a389".parse::<GitOid>()?;
398
399 let error = GitUrl::from_commit(
400 url.clone(),
401 GitReference::from_rev(requested_revision.to_string()),
402 precise,
403 GitLfs::Disabled,
404 )
405 .expect_err("mismatched full revision must be rejected");
406 assert_eq!(
407 error.to_string(),
408 "Exact Git revision `0dacfd662c64cb4ceb16e6cf65a157a8b715b979` does not match precise commit `b270df1a2fb5d012294e9aaf05e7e0bab1e6a389` for `https://git:****@example.com/pkg.git`"
409 );
410
411 let git = GitUrl::from_reference(
412 url.clone(),
413 GitReference::from_rev(requested_revision.to_string()),
414 GitLfs::Disabled,
415 )?;
416 assert!(git.with_precise(precise).is_err());
417
418 let uppercase_revision = requested_revision.to_ascii_uppercase();
419 let expected = requested_revision.parse::<GitOid>()?;
420 assert!(
421 GitUrl::from_commit(
422 url.clone(),
423 GitReference::from_rev(uppercase_revision),
424 expected,
425 GitLfs::Disabled,
426 )
427 .is_ok()
428 );
429
430 assert!(
431 GitUrl::from_commit(
432 url.clone(),
433 GitReference::from_rev("0dacfd6".to_string()),
434 precise,
435 GitLfs::Disabled,
436 )
437 .is_ok()
438 );
439
440 assert!(
441 GitUrl::from_commit(
442 url,
443 GitReference::Branch(requested_revision.to_string()),
444 precise,
445 GitLfs::Disabled,
446 )
447 .is_ok()
448 );
449
450 Ok(())
451 }
452
453 #[test]
454 fn changing_reference_clears_precise_commit() -> Result<(), Box<dyn std::error::Error>> {
455 let url = SafeUrl::parse("https://example.com/pkg.git")?;
456 let precise = "0dacfd662c64cb4ceb16e6cf65a157a8b715b979".parse::<GitOid>()?;
457 let reference = GitReference::Branch("main".to_string());
458 let git = GitUrl::from_commit(url, reference.clone(), precise, GitLfs::Disabled)?;
459
460 assert_eq!(
461 git.clone().with_reference(reference).precise(),
462 Some(precise)
463 );
464 let commit = "b270df1a2fb5d012294e9aaf05e7e0bab1e6a389".parse::<GitOid>()?;
465 let git = git.with_reference(GitReference::from_rev(commit.to_string()));
466 assert_eq!(git.precise(), Some(commit));
467 assert_eq!(
468 git.with_reference(GitReference::Branch("main".to_string()))
469 .precise(),
470 None
471 );
472
473 Ok(())
474 }
475
476 #[test]
477 fn display_percent_encodes_reference() -> Result<(), Box<dyn std::error::Error>> {
478 let git = GitUrl::from_reference(
479 SafeUrl::parse("https://example.com/pkg.git")?,
480 GitReference::from_rev("refs/pull/493/head@1#2%".to_string()),
481 GitLfs::Disabled,
482 )?;
483 let url = SafeUrl::from(git);
484
485 assert_eq!(
486 url.as_str(),
487 "https://example.com/pkg.git@refs/pull/493/head%401%232%25"
488 );
489
490 let git = GitUrl::try_from(url)?;
491 assert_eq!(git.reference().as_str(), Some("refs/pull/493/head@1#2%"));
492
493 Ok(())
494 }
495}