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
17pub static 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}
88
89#[derive(Debug, Clone)]
91pub struct GitUrl {
92 url: DisplaySafeUrl,
95 repository: RepositoryUrl,
97 reference: GitReference,
99 precise: Option<GitOid>,
101 lfs: GitLfs,
103}
104
105impl GitUrl {
106 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 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 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 #[must_use]
152 pub fn with_precise(mut self, precise: GitOid) -> Self {
153 self.precise = Some(precise);
154 self
155 }
156
157 #[must_use]
159 pub fn with_reference(mut self, reference: GitReference) -> Self {
160 self.reference = reference;
161 self
162 }
163
164 pub fn url(&self) -> &DisplaySafeUrl {
166 &self.url
167 }
168
169 pub fn repository(&self) -> &RepositoryUrl {
171 &self.repository
172 }
173
174 pub fn reference(&self) -> &GitReference {
176 &self.reference
177 }
178
179 pub fn precise(&self) -> Option<GitOid> {
181 self.precise
182 }
183
184 pub fn lfs(&self) -> GitLfs {
186 self.lfs
187 }
188
189 #[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 fn try_from(mut url: DisplaySafeUrl) -> Result<Self, Self::Error> {
238 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 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 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 let Some(precise) = git.precise {
270 let path = format!("{}@{}", url.path(), precise);
271 url.set_path(&path);
272 } else {
273 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}