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