Skip to main content

uv_git_types/
lib.rs

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
17/// Initialize [`GitLfs`] mode from `UV_GIT_LFS` environment.
18static UV_GIT_LFS: LazyLock<GitLfs> = LazyLock::new(|| {
19    // TODO(konsti): Parse this in `EnvironmentOptions`.
20    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/// Configuration for Git LFS (Large File Storage) support.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
32pub enum GitLfs {
33    /// Git LFS is disabled (default).
34    #[default]
35    Disabled,
36    /// Git LFS is enabled.
37    Enabled,
38}
39
40impl GitLfs {
41    /// Create a `GitLfs` configuration from environment variables.
42    pub fn from_env() -> Self {
43        *UV_GIT_LFS
44    }
45
46    /// Returns true if LFS is enabled.
47    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/// A URL reference to a Git repository.
98///
99/// Store the Git-specific fields out of line so ordinary registry and path requirements do
100/// not reserve space for both URLs, a Git reference, and a resolved commit.
101#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct GitUrl(Arc<GitUrlInner>);
103
104#[derive(Debug, Clone)]
105struct GitUrlInner {
106    /// The URL of the Git repository, with any query parameters, fragments, and leading `git+`
107    /// removed.
108    url: SafeUrl,
109    /// The canonical repository identity used for comparison and hashing.
110    repository: RepositoryUrl,
111    /// The reference to the commit to use, which could be a branch, tag or revision.
112    reference: GitReference,
113    /// The resolved commit for a named or ambiguous reference, if known. Exact commit references
114    /// store their commit in [`GitReference::Commit`] instead.
115    resolved: Option<GitOid>,
116    /// Git LFS configuration for this repository.
117    lfs: GitLfs,
118}
119
120impl GitUrl {
121    /// Create a new [`GitUrl`] from a repository URL and a reference.
122    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    /// Create a new [`GitUrl`] from a repository URL and a precise commit.
131    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    /// Create a new [`GitUrl`] from a repository URL and a precise commit, if known.
141    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    /// Set the precise [`GitOid`] to use for this Git URL.
171    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    /// Set the [`GitReference`] to use for this Git URL, clearing any resolved commit if it changes.
189    #[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    /// Return the [`Url`] of the Git repository.
200    pub fn url(&self) -> &SafeUrl {
201        &self.0.url
202    }
203
204    /// Return the canonical repository identity for this Git URL.
205    pub fn repository(&self) -> &RepositoryUrl {
206        &self.0.repository
207    }
208
209    /// Return the reference to the commit to use, which could be a branch, tag or revision.
210    pub fn reference(&self) -> &GitReference {
211        &self.0.reference
212    }
213
214    /// Return the precise commit, whether requested directly or resolved from a named reference.
215    pub fn precise(&self) -> Option<GitOid> {
216        self.0.reference.as_commit().or(self.resolved_commit())
217    }
218
219    /// Return the commit obtained by resolving a reference, if known.
220    ///
221    /// Returns `None` when the reference is itself a full commit.
222    pub fn resolved_commit(&self) -> Option<GitOid> {
223        self.0.resolved
224    }
225
226    /// Return the Git LFS configuration.
227    pub fn lfs(&self) -> GitLfs {
228        self.0.lfs
229    }
230
231    /// Set the Git LFS configuration.
232    #[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    /// Initialize a [`GitUrl`] source from a URL.
281    fn try_from(mut url: SafeUrl) -> Result<Self, Self::Error> {
282        // Remove any query parameters and fragments.
283        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        // If the URL ends with a reference, like `https://git.example.com/MyProject.git@v1.0`,
291        // extract it.
292        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        // TODO(samypr100): GitLfs::from_env() for now unless we want to support parsing lfs=true
304        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 we have a precise commit, add `@` and the commit hash to the URL.
315        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        // Explicit branches and tags can have names that look like full commit hashes.
388        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}