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::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
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, 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/// A URL reference to a Git repository.
98#[derive(Debug, Clone)]
99pub struct GitUrl {
100    /// The URL of the Git repository, with any query parameters, fragments, and leading `git+`
101    /// removed.
102    url: DisplaySafeUrl,
103    /// The canonical repository identity used for comparison and hashing.
104    repository: RepositoryUrl,
105    /// The reference to the commit to use, which could be a branch, tag or revision.
106    reference: GitReference,
107    /// The precise commit to use, if known.
108    precise: Option<GitOid>,
109    /// Git LFS configuration for this repository.
110    lfs: GitLfs,
111}
112
113impl GitUrl {
114    /// Create a new [`GitUrl`] from a repository URL and a reference.
115    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    /// Create a new [`GitUrl`] from a repository URL and a precise commit.
124    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    /// Create a new [`GitUrl`] from a repository URL and a precise commit, if known.
134    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    /// Set the precise [`GitOid`] to use for this Git URL.
164    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    /// Set the [`GitReference`] to use for this Git URL, clearing any precise commit if it changes.
181    #[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    /// Return the [`Url`] of the Git repository.
191    pub fn url(&self) -> &DisplaySafeUrl {
192        &self.url
193    }
194
195    /// Return the canonical repository identity for this Git URL.
196    pub fn repository(&self) -> &RepositoryUrl {
197        &self.repository
198    }
199
200    /// Return the reference to the commit to use, which could be a branch, tag or revision.
201    pub fn reference(&self) -> &GitReference {
202        &self.reference
203    }
204
205    /// Return the precise commit, if known.
206    pub fn precise(&self) -> Option<GitOid> {
207        self.precise
208    }
209
210    /// Return the Git LFS configuration.
211    pub fn lfs(&self) -> GitLfs {
212        self.lfs
213    }
214
215    /// Set the Git LFS configuration.
216    #[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    /// Initialize a [`GitUrl`] source from a URL.
263    fn try_from(mut url: DisplaySafeUrl) -> Result<Self, Self::Error> {
264        // Remove any query parameters and fragments.
265        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        // If the URL ends with a reference, like `https://git.example.com/MyProject.git@v1.0`,
273        // extract it.
274        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        // TODO(samypr100): GitLfs::from_env() for now unless we want to support parsing lfs=true
286        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 we have a precise commit, add `@` and the commit hash to the URL.
295        if let Some(precise) = git.precise {
296            let path = format!("{}@{}", url.path(), precise);
297            url.set_path(&path);
298        } else {
299            // Otherwise, add the branch or tag name.
300            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}