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.
18pub static 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}
88
89/// A URL reference to a Git repository.
90#[derive(Debug, Clone)]
91pub struct GitUrl {
92    /// The URL of the Git repository, with any query parameters, fragments, and leading `git+`
93    /// removed.
94    url: DisplaySafeUrl,
95    /// The canonical repository identity used for comparison and hashing.
96    repository: RepositoryUrl,
97    /// The reference to the commit to use, which could be a branch, tag or revision.
98    reference: GitReference,
99    /// The precise commit to use, if known.
100    precise: Option<GitOid>,
101    /// Git LFS configuration for this repository.
102    lfs: GitLfs,
103}
104
105impl GitUrl {
106    /// Create a new [`GitUrl`] from a repository URL and a reference.
107    pub fn from_reference(
108        url: DisplaySafeUrl,
109        reference: GitReference,
110        lfs: GitLfs,
111    ) -> Result<Self, GitUrlParseError> {
112        Self::from_fields(url, reference, None, lfs)
113    }
114
115    /// Create a new [`GitUrl`] from a repository URL and a precise commit.
116    pub fn from_commit(
117        url: DisplaySafeUrl,
118        reference: GitReference,
119        precise: GitOid,
120        lfs: GitLfs,
121    ) -> Result<Self, GitUrlParseError> {
122        Self::from_fields(url, reference, Some(precise), lfs)
123    }
124
125    /// Create a new [`GitUrl`] from a repository URL and a precise commit, if known.
126    pub fn from_fields(
127        url: DisplaySafeUrl,
128        reference: GitReference,
129        precise: Option<GitOid>,
130        lfs: GitLfs,
131    ) -> Result<Self, GitUrlParseError> {
132        match url.scheme() {
133            "http" | "https" | "ssh" | "file" => {}
134            unsupported => {
135                return Err(GitUrlParseError::UnsupportedGitScheme(
136                    unsupported.to_string(),
137                    url,
138                ));
139            }
140        }
141        Ok(Self {
142            repository: RepositoryUrl::new(&url),
143            url,
144            reference,
145            precise,
146            lfs,
147        })
148    }
149
150    /// Set the precise [`GitOid`] to use for this Git URL.
151    #[must_use]
152    pub fn with_precise(mut self, precise: GitOid) -> Self {
153        self.precise = Some(precise);
154        self
155    }
156
157    /// Set the [`GitReference`] to use for this Git URL.
158    #[must_use]
159    pub fn with_reference(mut self, reference: GitReference) -> Self {
160        self.reference = reference;
161        self
162    }
163
164    /// Return the [`Url`] of the Git repository.
165    pub fn url(&self) -> &DisplaySafeUrl {
166        &self.url
167    }
168
169    /// Return the canonical repository identity for this Git URL.
170    pub fn repository(&self) -> &RepositoryUrl {
171        &self.repository
172    }
173
174    /// Return the reference to the commit to use, which could be a branch, tag or revision.
175    pub fn reference(&self) -> &GitReference {
176        &self.reference
177    }
178
179    /// Return the precise commit, if known.
180    pub fn precise(&self) -> Option<GitOid> {
181        self.precise
182    }
183
184    /// Return the Git LFS configuration.
185    pub fn lfs(&self) -> GitLfs {
186        self.lfs
187    }
188
189    /// Set the Git LFS configuration.
190    #[must_use]
191    pub fn with_lfs(mut self, lfs: GitLfs) -> Self {
192        self.lfs = lfs;
193        self
194    }
195}
196
197impl PartialEq for GitUrl {
198    fn eq(&self, other: &Self) -> bool {
199        self.repository == other.repository
200            && self.reference == other.reference
201            && self.precise == other.precise
202            && self.lfs == other.lfs
203    }
204}
205
206impl Eq for GitUrl {}
207
208impl PartialOrd for GitUrl {
209    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
210        Some(self.cmp(other))
211    }
212}
213
214impl Ord for GitUrl {
215    fn cmp(&self, other: &Self) -> Ordering {
216        self.repository
217            .cmp(&other.repository)
218            .then_with(|| self.reference.cmp(&other.reference))
219            .then_with(|| self.precise.cmp(&other.precise))
220            .then_with(|| self.lfs.cmp(&other.lfs))
221    }
222}
223
224impl std::hash::Hash for GitUrl {
225    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
226        self.repository.hash(state);
227        self.reference.hash(state);
228        self.precise.hash(state);
229        self.lfs.hash(state);
230    }
231}
232
233impl TryFrom<DisplaySafeUrl> for GitUrl {
234    type Error = GitUrlParseError;
235
236    /// Initialize a [`GitUrl`] source from a URL.
237    fn try_from(mut url: DisplaySafeUrl) -> Result<Self, Self::Error> {
238        // Remove any query parameters and fragments.
239        url.set_fragment(None);
240        url.set_query(None);
241
242        if url.path().matches('@').nth(1).is_some() {
243            return Err(GitUrlParseError::AmbiguousRevision(url));
244        }
245
246        // If the URL ends with a reference, like `https://git.example.com/MyProject.git@v1.0`,
247        // extract it.
248        let mut reference = GitReference::DefaultBranch;
249        if let Some((prefix, suffix)) = url
250            .path()
251            .rsplit_once('@')
252            .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
253        {
254            let suffix = percent_decode_str(&suffix).decode_utf8_lossy().into_owned();
255            reference = GitReference::from_rev(suffix);
256            url.set_path(&prefix);
257        }
258
259        // TODO(samypr100): GitLfs::from_env() for now unless we want to support parsing lfs=true
260        Self::from_reference(url, reference, GitLfs::from_env())
261    }
262}
263
264impl From<GitUrl> for DisplaySafeUrl {
265    fn from(git: GitUrl) -> Self {
266        let mut url = git.url;
267
268        // If we have a precise commit, add `@` and the commit hash to the URL.
269        if let Some(precise) = git.precise {
270            let path = format!("{}@{}", url.path(), precise);
271            url.set_path(&path);
272        } else {
273            // Otherwise, add the branch or tag name.
274            match git.reference {
275                GitReference::Branch(rev)
276                | GitReference::Tag(rev)
277                | GitReference::BranchOrTag(rev)
278                | GitReference::NamedRef(rev)
279                | GitReference::BranchOrTagOrCommit(rev) => {
280                    let rev = GitReference::encode_rev(&rev);
281                    let path = format!("{}@{}", url.path(), rev);
282                    url.set_path(&path);
283                }
284                GitReference::DefaultBranch => {}
285            }
286        }
287
288        url
289    }
290}
291
292impl std::fmt::Display for GitUrl {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        write!(f, "{}", &self.url)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn parse_percent_encoded_reference() -> Result<(), Box<dyn std::error::Error>> {
304        let url = DisplaySafeUrl::parse("https://example.com/pkg.git@dev%401%232")?;
305        let git = GitUrl::try_from(url)?;
306
307        assert_eq!(git.url().as_str(), "https://example.com/pkg.git");
308        assert_eq!(git.reference().as_str(), Some("dev@1#2"));
309
310        Ok(())
311    }
312
313    #[test]
314    fn parse_ssh_url_with_username_and_percent_encoded_reference()
315    -> Result<(), Box<dyn std::error::Error>> {
316        let url = DisplaySafeUrl::parse("ssh://git@github.com/example/example.git@abc%401.2.3")?;
317        let git = GitUrl::try_from(url)?;
318
319        assert_eq!(
320            git.url().as_str(),
321            "ssh://git@github.com/example/example.git"
322        );
323        assert_eq!(git.reference().as_str(), Some("abc@1.2.3"));
324
325        Ok(())
326    }
327
328    #[test]
329    fn reject_ambiguous_reference() -> Result<(), Box<dyn std::error::Error>> {
330        let url = DisplaySafeUrl::parse("https://example.com/pkg.git@dev@1.2.3")?;
331        let err = GitUrl::try_from(url).unwrap_err();
332
333        assert_eq!(
334            err.to_string(),
335            "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`"
336        );
337
338        Ok(())
339    }
340
341    #[test]
342    fn display_percent_encodes_reference() -> Result<(), Box<dyn std::error::Error>> {
343        let git = GitUrl::from_reference(
344            DisplaySafeUrl::parse("https://example.com/pkg.git")?,
345            GitReference::from_rev("refs/pull/493/head@1#2%".to_string()),
346            GitLfs::Disabled,
347        )?;
348        let url = DisplaySafeUrl::from(git);
349
350        assert_eq!(
351            url.as_str(),
352            "https://example.com/pkg.git@refs/pull/493/head%401%232%25"
353        );
354
355        let git = GitUrl::try_from(url)?;
356        assert_eq!(git.reference().as_str(), Some("refs/pull/493/head@1#2%"));
357
358        Ok(())
359    }
360}