1use std::fmt::Display;
5use std::path::{Path, PathBuf};
6use std::str::{self};
7use std::sync::LazyLock;
8
9use anyhow::{Context, Result, anyhow};
10use cargo_util::{ProcessBuilder, paths};
11use owo_colors::OwoColorize;
12use tracing::{debug, instrument, warn};
13use url::Url;
14
15use uv_fs::Simplified;
16use uv_git_types::{GitOid, GitReference};
17use uv_redacted::DisplaySafeUrl;
18use uv_static::EnvVars;
19use uv_warnings::warn_user_once;
20
21const CHECKOUT_READY_LOCK: &str = ".ok";
24
25#[derive(Debug, thiserror::Error)]
26pub enum GitError {
27 #[error("Git executable not found. Ensure that Git is installed and available.")]
28 GitNotFound,
29 #[error("Git LFS extension not found. Ensure that Git LFS is installed and available.")]
30 GitLfsNotFound,
31 #[error("Is Git LFS configured? Run `{}` to initialize Git LFS.", "git lfs install".green())]
32 GitLfsNotConfigured,
33 #[error(transparent)]
34 Other(#[from] which::Error),
35 #[error(
36 "Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
37 )]
38 TransportNotAllowed,
39}
40
41pub static GIT: LazyLock<Result<PathBuf, GitError>> = LazyLock::new(|| {
43 which::which("git").map_err(|err| match err {
44 which::Error::CannotFindBinaryPath => GitError::GitNotFound,
45 err => GitError::Other(err),
46 })
47});
48
49enum RefspecStrategy {
51 All,
53 First,
55}
56
57#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
59enum ReferenceOrOid<'reference> {
60 Reference(&'reference GitReference),
62 Oid(GitOid),
64}
65
66impl ReferenceOrOid<'_> {
67 fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
69 let refkind = self.kind_str();
70 let result = match self {
71 Self::Reference(GitReference::Tag(s)) => {
76 repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
77 }
78
79 Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
81
82 Self::Reference(GitReference::BranchOrTag(s)) => repo
84 .rev_parse(&format!("origin/{s}^0"))
85 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
86
87 Self::Reference(GitReference::BranchOrTagOrCommit(s)) => repo
89 .rev_parse(&format!("origin/{s}^0"))
90 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
91 .or_else(|_| repo.rev_parse(&format!("{s}^0"))),
92
93 Self::Reference(GitReference::DefaultBranch) => {
95 repo.rev_parse("refs/remotes/origin/HEAD")
96 }
97
98 Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
100
101 Self::Oid(s) => repo.rev_parse(&format!("{s}^0")),
103 };
104
105 result.with_context(|| anyhow::format_err!("failed to find {refkind} `{self}`"))
106 }
107
108 fn kind_str(&self) -> &str {
110 match self {
111 Self::Reference(reference) => reference.kind_str(),
112 Self::Oid(_) => "commit",
113 }
114 }
115
116 fn as_rev(&self) -> &str {
118 match self {
119 Self::Reference(r) => r.as_rev(),
120 Self::Oid(rev) => rev.as_str(),
121 }
122 }
123}
124
125impl Display for ReferenceOrOid<'_> {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 match self {
128 Self::Reference(reference) => write!(f, "{reference}"),
129 Self::Oid(oid) => write!(f, "{oid}"),
130 }
131 }
132}
133
134#[derive(PartialEq, Clone, Debug)]
136pub(crate) struct GitRemote {
137 url: DisplaySafeUrl,
139}
140
141pub(crate) struct GitDatabase {
144 repo: GitRepository,
146 lfs_ready: Option<bool>,
148}
149
150pub(crate) struct GitCheckout {
152 revision: GitOid,
154 repo: GitRepository,
156 lfs_ready: Option<bool>,
158}
159
160pub(crate) struct GitRepository {
162 path: PathBuf,
164}
165
166impl GitRepository {
167 pub(crate) fn open(path: &Path) -> Result<Self> {
169 ProcessBuilder::new(GIT.as_ref()?)
171 .arg("rev-parse")
172 .cwd(path)
173 .exec_with_output()?;
174
175 Ok(Self {
176 path: path.to_path_buf(),
177 })
178 }
179
180 fn init(path: &Path) -> Result<Self> {
182 ProcessBuilder::new(GIT.as_ref()?)
190 .arg("init")
191 .cwd(path)
192 .exec_with_output()?;
193
194 Ok(Self {
195 path: path.to_path_buf(),
196 })
197 }
198
199 fn rev_parse(&self, refname: &str) -> Result<GitOid> {
201 let result = ProcessBuilder::new(GIT.as_ref()?)
202 .arg("rev-parse")
203 .arg(refname)
204 .cwd(&self.path)
205 .exec_with_output()?;
206
207 let mut result = String::from_utf8(result.stdout)?;
208 result.truncate(result.trim_end().len());
209 Ok(result.parse()?)
210 }
211
212 #[instrument(skip_all, fields(path = %self.path.user_display(), refname = %refname))]
214 fn lfs_fsck_objects(&self, refname: &str) -> bool {
215 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
216 lfs.clone()
217 } else {
218 warn!("Git LFS is not available, skipping LFS fetch");
219 return false;
220 };
221
222 let result = cmd
224 .arg("fsck")
225 .arg("--objects")
226 .arg(refname)
227 .cwd(&self.path)
228 .exec_with_output();
229
230 match result {
231 Ok(_) => true,
232 Err(err) => {
233 let lfs_error = err.to_string();
234 if lfs_error.contains("unknown flag: --objects") {
235 warn_user_once!(
236 "Skipping Git LFS validation as Git LFS extension is outdated. \
237 Upgrade to `git-lfs>=3.0.2` or manually verify git-lfs objects were \
238 properly fetched after the current operation finishes."
239 );
240 true
241 } else {
242 debug!("Git LFS validation failed: {err}");
243 false
244 }
245 }
246 }
247 }
248}
249
250impl GitRemote {
251 pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
253 Self { url: url.clone() }
254 }
255
256 pub(crate) fn url(&self) -> &DisplaySafeUrl {
258 &self.url
259 }
260
261 pub(crate) fn checkout(
274 &self,
275 into: &Path,
276 db: Option<GitDatabase>,
277 reference: &GitReference,
278 locked_rev: Option<GitOid>,
279 disable_ssl: bool,
280 offline: bool,
281 with_lfs: bool,
282 ) -> Result<(GitDatabase, GitOid)> {
283 let reference = locked_rev
284 .map(ReferenceOrOid::Oid)
285 .unwrap_or(ReferenceOrOid::Reference(reference));
286 if let Some(mut db) = db {
287 fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
288 .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
289
290 let resolved_commit_hash = match locked_rev {
291 Some(rev) => db.contains(rev).then_some(rev),
292 None => reference.resolve(&db.repo).ok(),
293 };
294
295 if let Some(rev) = resolved_commit_hash {
296 if with_lfs {
297 let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
298 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
299 db = db.with_lfs_ready(Some(lfs_ready));
300 }
301 return Ok((db, rev));
302 }
303 }
304
305 match fs_err::remove_dir_all(into) {
309 Ok(()) => {}
310 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
311 Err(e) => return Err(e.into()),
312 }
313
314 fs_err::create_dir_all(into)?;
315 let mut repo = GitRepository::init(into)?;
316 fetch(&mut repo, &self.url, reference, disable_ssl, offline)
317 .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
318 let rev = match locked_rev {
319 Some(rev) => rev,
320 None => reference.resolve(&repo)?,
321 };
322 let lfs_ready = with_lfs
323 .then(|| {
324 fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
325 .with_context(|| format!("failed to fetch LFS objects at {rev}"))
326 })
327 .transpose()?;
328
329 Ok((GitDatabase { repo, lfs_ready }, rev))
330 }
331
332 #[allow(clippy::unused_self)]
334 pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
335 let repo = GitRepository::open(db_path)?;
336 Ok(GitDatabase {
337 repo,
338 lfs_ready: None,
339 })
340 }
341}
342
343impl GitDatabase {
344 pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
346 let checkout = match GitRepository::open(destination)
351 .ok()
352 .map(|repo| GitCheckout::new(rev, repo))
353 .filter(GitCheckout::is_fresh)
354 {
355 Some(co) => co.with_lfs_ready(self.lfs_ready),
356 None => GitCheckout::clone_into(destination, self, rev)?,
357 };
358 Ok(checkout)
359 }
360
361 pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
363 let output = ProcessBuilder::new(GIT.as_ref()?)
364 .arg("rev-parse")
365 .arg("--short")
366 .arg(revision.as_str())
367 .cwd(&self.repo.path)
368 .exec_with_output()?;
369
370 let mut result = String::from_utf8(output.stdout)?;
371 result.truncate(result.trim_end().len());
372 Ok(result)
373 }
374
375 pub(crate) fn contains(&self, oid: GitOid) -> bool {
377 self.repo.rev_parse(&format!("{oid}^0")).is_ok()
378 }
379
380 pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
382 self.repo.lfs_fsck_objects(&format!("{oid}^0"))
383 }
384
385 #[must_use]
387 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
388 self.lfs_ready = lfs;
389 self
390 }
391}
392
393impl GitCheckout {
394 fn new(revision: GitOid, repo: GitRepository) -> Self {
399 Self {
400 revision,
401 repo,
402 lfs_ready: None,
403 }
404 }
405
406 fn clone_into(into: &Path, database: &GitDatabase, revision: GitOid) -> Result<Self> {
409 let dirname = into.parent().unwrap();
410 fs_err::create_dir_all(dirname)?;
411 match fs_err::remove_dir_all(into) {
412 Ok(()) => {}
413 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
414 Err(e) => return Err(e.into()),
415 }
416
417 let res = ProcessBuilder::new(GIT.as_ref()?)
421 .arg("clone")
422 .arg("--local")
423 .arg(database.repo.path.simplified_display().to_string())
427 .arg(into.simplified_display().to_string())
428 .exec_with_output();
429
430 if let Err(e) = res {
431 debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
432
433 ProcessBuilder::new(GIT.as_ref()?)
434 .arg("clone")
435 .arg("--no-hardlinks")
436 .arg(database.repo.path.simplified_display().to_string())
437 .arg(into.simplified_display().to_string())
438 .exec_with_output()?;
439 }
440
441 let repo = GitRepository::open(into)?;
442 let checkout = Self::new(revision, repo);
443 let lfs_ready = checkout.reset(database.lfs_ready)?;
444 Ok(checkout.with_lfs_ready(lfs_ready))
445 }
446
447 fn is_fresh(&self) -> bool {
449 match self.repo.rev_parse("HEAD") {
450 Ok(id) if id == self.revision => {
451 self.repo.path.join(CHECKOUT_READY_LOCK).exists()
453 }
454 _ => false,
455 }
456 }
457
458 pub(crate) fn lfs_ready(&self) -> Option<bool> {
460 self.lfs_ready
461 }
462
463 #[must_use]
465 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
466 self.lfs_ready = lfs;
467 self
468 }
469
470 fn reset(&self, with_lfs: Option<bool>) -> Result<Option<bool>> {
484 let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
485 let _ = paths::remove_file(&ok_file);
486
487 let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
491 debug!("Reset {} to {}", self.repo.path.display(), self.revision);
492
493 ProcessBuilder::new(GIT.as_ref()?)
495 .arg("reset")
496 .arg("--hard")
497 .arg(self.revision.as_str())
498 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
499 .cwd(&self.repo.path)
500 .exec_with_output()?;
501
502 ProcessBuilder::new(GIT.as_ref()?)
504 .arg("submodule")
505 .arg("update")
506 .arg("--recursive")
507 .arg("--init")
508 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
509 .cwd(&self.repo.path)
510 .exec_with_output()
511 .map(drop)?;
512
513 let lfs_validation = match with_lfs {
516 None => None,
517 Some(false) => Some(false),
518 Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
519 };
520
521 if with_lfs.is_none() || lfs_validation == Some(true) {
525 paths::create(ok_file)?;
526 }
527
528 Ok(lfs_validation)
529 }
530}
531
532fn fetch(
541 repo: &mut GitRepository,
542 remote_url: &Url,
543 reference: ReferenceOrOid<'_>,
544 disable_ssl: bool,
545 offline: bool,
546) -> Result<()> {
547 let oid_to_fetch = if let ReferenceOrOid::Oid(rev) = reference {
548 let local_object = reference.resolve(repo).ok();
549 if let Some(local_object) = local_object {
550 if rev == local_object {
551 return Ok(());
552 }
553 }
554
555 Some(rev)
558 } else {
559 None
560 };
561
562 let mut refspecs = Vec::new();
565 let mut tags = false;
566 let mut refspec_strategy = RefspecStrategy::All;
567 match reference {
571 ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
574 refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
575 }
576
577 ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
578 refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
579 }
580
581 ReferenceOrOid::Reference(GitReference::BranchOrTag(branch_or_tag)) => {
582 refspecs.push(format!(
583 "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
584 ));
585 refspecs.push(format!(
586 "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
587 ));
588 refspec_strategy = RefspecStrategy::First;
589 }
590
591 ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
594 if let Some(oid_to_fetch) =
598 oid_to_fetch.filter(|oid| is_short_hash_of(branch_or_tag_or_commit, *oid))
599 {
600 refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
601 } else {
602 refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
606 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
607 tags = true;
608 }
609 }
610
611 ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
612 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
613 }
614
615 ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
616 refspecs.push(format!("+{rev}:{rev}"));
617 }
618
619 ReferenceOrOid::Oid(rev) => {
620 refspecs.push(format!("+{rev}:refs/commit/{rev}"));
621 }
622 }
623
624 debug!("Performing a Git fetch for: {remote_url}");
625 let result = match refspec_strategy {
626 RefspecStrategy::All => fetch_with_cli(
627 repo,
628 remote_url,
629 refspecs.as_slice(),
630 tags,
631 disable_ssl,
632 offline,
633 ),
634 RefspecStrategy::First => {
635 let mut errors = refspecs
637 .iter()
638 .map_while(|refspec| {
639 let fetch_result = fetch_with_cli(
640 repo,
641 remote_url,
642 std::slice::from_ref(refspec),
643 tags,
644 disable_ssl,
645 offline,
646 );
647
648 match fetch_result {
650 Err(ref err) => {
651 debug!("Failed to fetch refspec `{refspec}`: {err}");
652 Some(fetch_result)
653 }
654 Ok(()) => None,
655 }
656 })
657 .collect::<Vec<_>>();
658
659 if errors.len() == refspecs.len() {
660 if let Some(result) = errors.pop() {
661 result
663 } else {
664 Ok(())
666 }
667 } else {
668 Ok(())
669 }
670 }
671 };
672 match reference {
673 ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
675 _ => result.with_context(|| {
676 format!(
677 "failed to fetch {} `{}`",
678 reference.kind_str(),
679 reference.as_rev()
680 )
681 }),
682 }
683}
684
685fn fetch_with_cli(
687 repo: &mut GitRepository,
688 url: &Url,
689 refspecs: &[String],
690 tags: bool,
691 disable_ssl: bool,
692 offline: bool,
693) -> Result<()> {
694 let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
695 cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
699
700 cmd.arg("fetch");
701 if tags {
702 cmd.arg("--tags");
703 }
704 if disable_ssl {
705 debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
706 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
707 }
708 if offline {
709 debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
710 cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
711 }
712 cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
715 .args(refspecs)
716 .env_remove(EnvVars::GIT_DIR)
721 .env_remove(EnvVars::GIT_WORK_TREE)
724 .env_remove(EnvVars::GIT_INDEX_FILE)
725 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
726 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
727 .cwd(&repo.path);
728
729 cmd.exec_with_output().map_err(|err| {
733 let msg = err.to_string();
734 if msg.contains("transport '") && msg.contains("' not allowed") && offline {
735 return GitError::TransportNotAllowed.into();
736 }
737 err
738 })?;
739
740 Ok(())
741}
742
743pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
754 if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
755 return Err(anyhow!("Git LFS extension has been forcefully disabled."));
756 }
757
758 let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
759 cmd.arg("lfs");
760
761 cmd.clone().arg("version").exec_with_output()?;
763 Ok(cmd)
764});
765
766fn fetch_lfs(
768 repo: &mut GitRepository,
769 url: &Url,
770 revision: &GitOid,
771 disable_ssl: bool,
772) -> Result<bool> {
773 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
774 debug!("Fetching Git LFS objects");
775 lfs.clone()
776 } else {
777 warn!("Git LFS is not available, skipping LFS fetch");
779 return Ok(false);
780 };
781
782 if disable_ssl {
783 debug!("Disabling SSL verification for Git LFS");
784 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
785 }
786
787 cmd.arg("fetch")
788 .arg(url.as_str())
789 .arg(revision.as_str())
790 .env_remove(EnvVars::GIT_DIR)
792 .env_remove(EnvVars::GIT_WORK_TREE)
793 .env_remove(EnvVars::GIT_INDEX_FILE)
794 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
795 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
796 .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
799 .cwd(&repo.path);
800
801 cmd.exec_with_output()?;
802
803 let validation_result = repo.lfs_fsck_objects(revision.as_str());
811
812 Ok(validation_result)
813}
814
815fn is_short_hash_of(rev: &str, oid: GitOid) -> bool {
817 let long_hash = oid.to_string();
818 match long_hash.get(..rev.len()) {
819 Some(truncated_long_hash) => truncated_long_hash.eq_ignore_ascii_case(rev),
820 None => false,
821 }
822}