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::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#[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: SafeUrl,
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 resolved commit for a named or ambiguous reference, if known. Exact commit references
108    /// store their commit in [`GitReference::Commit`] instead.
109    resolved: Option<GitOid>,
110    /// Git LFS configuration for this repository.
111    lfs: GitLfs,
112}
113
114impl GitUrl {
115    /// Create a new [`GitUrl`] from a repository URL and a reference.
116    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    /// Create a new [`GitUrl`] from a repository URL and a precise commit.
125    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    /// Create a new [`GitUrl`] from a repository URL and a precise commit, if known.
135    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    /// Set the precise [`GitOid`] to use for this Git URL.
165    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    /// Set the [`GitReference`] to use for this Git URL, clearing any resolved commit if it changes.
183    #[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    /// Return the [`Url`] of the Git repository.
193    pub fn url(&self) -> &SafeUrl {
194        &self.url
195    }
196
197    /// Return the canonical repository identity for this Git URL.
198    pub fn repository(&self) -> &RepositoryUrl {
199        &self.repository
200    }
201
202    /// Return the reference to the commit to use, which could be a branch, tag or revision.
203    pub fn reference(&self) -> &GitReference {
204        &self.reference
205    }
206
207    /// Return the precise commit, whether requested directly or resolved from a named reference.
208    pub fn precise(&self) -> Option<GitOid> {
209        self.reference.as_commit().or(self.resolved_commit())
210    }
211
212    /// Return the commit obtained by resolving a reference, if known.
213    ///
214    /// Returns `None` when the reference is itself a full commit.
215    pub fn resolved_commit(&self) -> Option<GitOid> {
216        self.resolved
217    }
218
219    /// Return the Git LFS configuration.
220    pub fn lfs(&self) -> GitLfs {
221        self.lfs
222    }
223
224    /// Set the Git LFS configuration.
225    #[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    /// Initialize a [`GitUrl`] source from a URL.
272    fn try_from(mut url: SafeUrl) -> Result<Self, Self::Error> {
273        // Remove any query parameters and fragments.
274        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        // If the URL ends with a reference, like `https://git.example.com/MyProject.git@v1.0`,
282        // extract it.
283        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        // TODO(samypr100): GitLfs::from_env() for now unless we want to support parsing lfs=true
295        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 we have a precise commit, add `@` and the commit hash to the URL.
305        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        // Explicit branches and tags can have names that look like full commit hashes.
378        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}