1use std::fmt::Display;
5use std::path::{Path, PathBuf};
6use std::str::{self};
7use std::sync::LazyLock;
8
9use anyhow::{Context, Result};
10use cargo_util::{ProcessBuilder, paths};
11use tracing::{debug, warn};
12use url::Url;
13
14use uv_fs::Simplified;
15use uv_git_types::{GitOid, GitReference};
16use uv_redacted::DisplaySafeUrl;
17use uv_static::EnvVars;
18
19const CHECKOUT_READY_LOCK: &str = ".ok";
22
23#[derive(Debug, thiserror::Error)]
24pub enum GitError {
25 #[error("Git executable not found. Ensure that Git is installed and available.")]
26 GitNotFound,
27 #[error(transparent)]
28 Other(#[from] which::Error),
29 #[error(
30 "Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
31 )]
32 TransportNotAllowed,
33}
34
35pub static GIT: LazyLock<Result<PathBuf, GitError>> = LazyLock::new(|| {
37 which::which("git").map_err(|err| match err {
38 which::Error::CannotFindBinaryPath => GitError::GitNotFound,
39 err => GitError::Other(err),
40 })
41});
42
43enum RefspecStrategy {
45 All,
47 First,
49}
50
51#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
53enum ReferenceOrOid<'reference> {
54 Reference(&'reference GitReference),
56 Oid(GitOid),
58}
59
60impl ReferenceOrOid<'_> {
61 fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
63 let refkind = self.kind_str();
64 let result = match self {
65 Self::Reference(GitReference::Tag(s)) => {
70 repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
71 }
72
73 Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
75
76 Self::Reference(GitReference::BranchOrTag(s)) => repo
78 .rev_parse(&format!("origin/{s}^0"))
79 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
80
81 Self::Reference(GitReference::BranchOrTagOrCommit(s)) => repo
83 .rev_parse(&format!("origin/{s}^0"))
84 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
85 .or_else(|_| repo.rev_parse(&format!("{s}^0"))),
86
87 Self::Reference(GitReference::DefaultBranch) => {
89 repo.rev_parse("refs/remotes/origin/HEAD")
90 }
91
92 Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
94
95 Self::Oid(s) => repo.rev_parse(&format!("{s}^0")),
97 };
98
99 result.with_context(|| anyhow::format_err!("failed to find {refkind} `{self}`"))
100 }
101
102 fn kind_str(&self) -> &str {
104 match self {
105 Self::Reference(reference) => reference.kind_str(),
106 Self::Oid(_) => "commit",
107 }
108 }
109
110 fn as_rev(&self) -> &str {
112 match self {
113 Self::Reference(r) => r.as_rev(),
114 Self::Oid(rev) => rev.as_str(),
115 }
116 }
117}
118
119impl Display for ReferenceOrOid<'_> {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 Self::Reference(reference) => write!(f, "{reference}"),
123 Self::Oid(oid) => write!(f, "{oid}"),
124 }
125 }
126}
127
128#[derive(PartialEq, Clone, Debug)]
130pub(crate) struct GitRemote {
131 url: DisplaySafeUrl,
133}
134
135pub(crate) struct GitDatabase {
138 repo: GitRepository,
140}
141
142pub(crate) struct GitCheckout {
144 revision: GitOid,
146 repo: GitRepository,
148}
149
150pub(crate) struct GitRepository {
152 path: PathBuf,
154}
155
156impl GitRepository {
157 pub(crate) fn open(path: &Path) -> Result<Self> {
159 ProcessBuilder::new(GIT.as_ref()?)
161 .arg("rev-parse")
162 .cwd(path)
163 .exec_with_output()?;
164
165 Ok(Self {
166 path: path.to_path_buf(),
167 })
168 }
169
170 fn init(path: &Path) -> Result<Self> {
172 ProcessBuilder::new(GIT.as_ref()?)
180 .arg("init")
181 .cwd(path)
182 .exec_with_output()?;
183
184 Ok(Self {
185 path: path.to_path_buf(),
186 })
187 }
188
189 fn rev_parse(&self, refname: &str) -> Result<GitOid> {
191 let result = ProcessBuilder::new(GIT.as_ref()?)
192 .arg("rev-parse")
193 .arg(refname)
194 .cwd(&self.path)
195 .exec_with_output()?;
196
197 let mut result = String::from_utf8(result.stdout)?;
198 result.truncate(result.trim_end().len());
199 Ok(result.parse()?)
200 }
201}
202
203impl GitRemote {
204 pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
206 Self { url: url.clone() }
207 }
208
209 pub(crate) fn url(&self) -> &DisplaySafeUrl {
211 &self.url
212 }
213
214 pub(crate) fn checkout(
227 &self,
228 into: &Path,
229 db: Option<GitDatabase>,
230 reference: &GitReference,
231 locked_rev: Option<GitOid>,
232 disable_ssl: bool,
233 offline: bool,
234 ) -> Result<(GitDatabase, GitOid)> {
235 let reference = locked_rev
236 .map(ReferenceOrOid::Oid)
237 .unwrap_or(ReferenceOrOid::Reference(reference));
238 let enable_lfs_fetch = std::env::var(EnvVars::UV_GIT_LFS).is_ok();
239
240 if let Some(mut db) = db {
241 fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
242 .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
243
244 let resolved_commit_hash = match locked_rev {
245 Some(rev) => db.contains(rev).then_some(rev),
246 None => reference.resolve(&db.repo).ok(),
247 };
248
249 if let Some(rev) = resolved_commit_hash {
250 if enable_lfs_fetch {
251 fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
252 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
253 }
254 return Ok((db, rev));
255 }
256 }
257
258 match fs_err::remove_dir_all(into) {
262 Ok(()) => {}
263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
264 Err(e) => return Err(e.into()),
265 }
266
267 fs_err::create_dir_all(into)?;
268 let mut repo = GitRepository::init(into)?;
269 fetch(&mut repo, &self.url, reference, disable_ssl, offline)
270 .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
271 let rev = match locked_rev {
272 Some(rev) => rev,
273 None => reference.resolve(&repo)?,
274 };
275 if enable_lfs_fetch {
276 fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
277 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
278 }
279
280 Ok((GitDatabase { repo }, rev))
281 }
282
283 #[allow(clippy::unused_self)]
285 pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
286 let repo = GitRepository::open(db_path)?;
287 Ok(GitDatabase { repo })
288 }
289}
290
291impl GitDatabase {
292 pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
294 let checkout = match GitRepository::open(destination)
299 .ok()
300 .map(|repo| GitCheckout::new(rev, repo))
301 .filter(GitCheckout::is_fresh)
302 {
303 Some(co) => co,
304 None => GitCheckout::clone_into(destination, self, rev)?,
305 };
306 Ok(checkout)
307 }
308
309 pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
311 let output = ProcessBuilder::new(GIT.as_ref()?)
312 .arg("rev-parse")
313 .arg("--short")
314 .arg(revision.as_str())
315 .cwd(&self.repo.path)
316 .exec_with_output()?;
317
318 let mut result = String::from_utf8(output.stdout)?;
319 result.truncate(result.trim_end().len());
320 Ok(result)
321 }
322
323 pub(crate) fn contains(&self, oid: GitOid) -> bool {
325 self.repo.rev_parse(&format!("{oid}^0")).is_ok()
326 }
327}
328
329impl GitCheckout {
330 fn new(revision: GitOid, repo: GitRepository) -> Self {
335 Self { revision, repo }
336 }
337
338 fn clone_into(into: &Path, database: &GitDatabase, revision: GitOid) -> Result<Self> {
341 let dirname = into.parent().unwrap();
342 fs_err::create_dir_all(dirname)?;
343 match fs_err::remove_dir_all(into) {
344 Ok(()) => {}
345 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
346 Err(e) => return Err(e.into()),
347 }
348
349 let res = ProcessBuilder::new(GIT.as_ref()?)
353 .arg("clone")
354 .arg("--local")
355 .arg(database.repo.path.simplified_display().to_string())
359 .arg(into.simplified_display().to_string())
360 .exec_with_output();
361
362 if let Err(e) = res {
363 debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
364
365 ProcessBuilder::new(GIT.as_ref()?)
366 .arg("clone")
367 .arg("--no-hardlinks")
368 .arg(database.repo.path.simplified_display().to_string())
369 .arg(into.simplified_display().to_string())
370 .exec_with_output()?;
371 }
372
373 let repo = GitRepository::open(into)?;
374 let checkout = Self::new(revision, repo);
375 checkout.reset()?;
376 Ok(checkout)
377 }
378
379 fn is_fresh(&self) -> bool {
381 match self.repo.rev_parse("HEAD") {
382 Ok(id) if id == self.revision => {
383 self.repo.path.join(CHECKOUT_READY_LOCK).exists()
385 }
386 _ => false,
387 }
388 }
389
390 fn reset(&self) -> Result<()> {
404 let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
405 let _ = paths::remove_file(&ok_file);
406 debug!("Reset {} to {}", self.repo.path.display(), self.revision);
407
408 ProcessBuilder::new(GIT.as_ref()?)
410 .arg("reset")
411 .arg("--hard")
412 .arg(self.revision.as_str())
413 .cwd(&self.repo.path)
414 .exec_with_output()?;
415
416 ProcessBuilder::new(GIT.as_ref()?)
418 .arg("submodule")
419 .arg("update")
420 .arg("--recursive")
421 .arg("--init")
422 .cwd(&self.repo.path)
423 .exec_with_output()
424 .map(drop)?;
425
426 paths::create(ok_file)?;
427 Ok(())
428 }
429}
430
431fn fetch(
440 repo: &mut GitRepository,
441 remote_url: &Url,
442 reference: ReferenceOrOid<'_>,
443 disable_ssl: bool,
444 offline: bool,
445) -> Result<()> {
446 let oid_to_fetch = if let ReferenceOrOid::Oid(rev) = reference {
447 let local_object = reference.resolve(repo).ok();
448 if let Some(local_object) = local_object {
449 if rev == local_object {
450 return Ok(());
451 }
452 }
453
454 Some(rev)
457 } else {
458 None
459 };
460
461 let mut refspecs = Vec::new();
464 let mut tags = false;
465 let mut refspec_strategy = RefspecStrategy::All;
466 match reference {
470 ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
473 refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
474 }
475
476 ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
477 refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
478 }
479
480 ReferenceOrOid::Reference(GitReference::BranchOrTag(branch_or_tag)) => {
481 refspecs.push(format!(
482 "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
483 ));
484 refspecs.push(format!(
485 "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
486 ));
487 refspec_strategy = RefspecStrategy::First;
488 }
489
490 ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
493 if let Some(oid_to_fetch) =
497 oid_to_fetch.filter(|oid| is_short_hash_of(branch_or_tag_or_commit, *oid))
498 {
499 refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
500 } else {
501 refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
505 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
506 tags = true;
507 }
508 }
509
510 ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
511 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
512 }
513
514 ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
515 refspecs.push(format!("+{rev}:{rev}"));
516 }
517
518 ReferenceOrOid::Oid(rev) => {
519 refspecs.push(format!("+{rev}:refs/commit/{rev}"));
520 }
521 }
522
523 debug!("Performing a Git fetch for: {remote_url}");
524 let result = match refspec_strategy {
525 RefspecStrategy::All => fetch_with_cli(
526 repo,
527 remote_url,
528 refspecs.as_slice(),
529 tags,
530 disable_ssl,
531 offline,
532 ),
533 RefspecStrategy::First => {
534 let mut errors = refspecs
536 .iter()
537 .map_while(|refspec| {
538 let fetch_result = fetch_with_cli(
539 repo,
540 remote_url,
541 std::slice::from_ref(refspec),
542 tags,
543 disable_ssl,
544 offline,
545 );
546
547 match fetch_result {
549 Err(ref err) => {
550 debug!("Failed to fetch refspec `{refspec}`: {err}");
551 Some(fetch_result)
552 }
553 Ok(()) => None,
554 }
555 })
556 .collect::<Vec<_>>();
557
558 if errors.len() == refspecs.len() {
559 if let Some(result) = errors.pop() {
560 result
562 } else {
563 Ok(())
565 }
566 } else {
567 Ok(())
568 }
569 }
570 };
571 match reference {
572 ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
574 _ => result.with_context(|| {
575 format!(
576 "failed to fetch {} `{}`",
577 reference.kind_str(),
578 reference.as_rev()
579 )
580 }),
581 }
582}
583
584fn fetch_with_cli(
586 repo: &mut GitRepository,
587 url: &Url,
588 refspecs: &[String],
589 tags: bool,
590 disable_ssl: bool,
591 offline: bool,
592) -> Result<()> {
593 let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
594 cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
598
599 cmd.arg("fetch");
600 if tags {
601 cmd.arg("--tags");
602 }
603 if disable_ssl {
604 debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
605 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
606 }
607 if offline {
608 debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
609 cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
610 }
611 cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
614 .args(refspecs)
615 .env_remove(EnvVars::GIT_DIR)
620 .env_remove(EnvVars::GIT_WORK_TREE)
623 .env_remove(EnvVars::GIT_INDEX_FILE)
624 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
625 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
626 .cwd(&repo.path);
627
628 cmd.exec_with_output().map_err(|err| {
632 let msg = err.to_string();
633 if msg.contains("transport '") && msg.contains("' not allowed") && offline {
634 return GitError::TransportNotAllowed.into();
635 }
636 err
637 })?;
638
639 Ok(())
640}
641
642static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
647 let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
648 cmd.arg("lfs");
649
650 cmd.clone().arg("version").exec_with_output()?;
652 Ok(cmd)
653});
654
655fn fetch_lfs(
657 repo: &mut GitRepository,
658 url: &Url,
659 revision: &GitOid,
660 disable_ssl: bool,
661) -> Result<()> {
662 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
663 debug!("Fetching Git LFS objects");
664 lfs.clone()
665 } else {
666 warn!("Git LFS is not available, skipping LFS fetch");
668 return Ok(());
669 };
670
671 if disable_ssl {
672 debug!("Disabling SSL verification for Git LFS");
673 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
674 }
675
676 cmd.arg("fetch")
677 .arg(url.as_str())
678 .arg(revision.as_str())
679 .env_remove(EnvVars::GIT_DIR)
681 .env_remove(EnvVars::GIT_WORK_TREE)
682 .env_remove(EnvVars::GIT_INDEX_FILE)
683 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
684 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
685 .cwd(&repo.path);
686
687 cmd.exec_with_output()?;
688 Ok(())
689}
690
691fn is_short_hash_of(rev: &str, oid: GitOid) -> bool {
693 let long_hash = oid.to_string();
694 match long_hash.get(..rev.len()) {
695 Some(truncated_long_hash) => truncated_long_hash.eq_ignore_ascii_case(rev),
696 None => false,
697 }
698}