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};
13
14use uv_fs::Simplified;
15use uv_git_types::{GitOid, GitReference};
16use uv_redacted::DisplaySafeUrl;
17use uv_static::EnvVars;
18use uv_warnings::warn_user_once;
19
20const CHECKOUT_READY_LOCK: &str = ".ok";
23
24#[derive(Debug, thiserror::Error)]
25pub enum GitError {
26 #[error("Git executable not found. Ensure that Git is installed and available.")]
27 GitNotFound,
28 #[error("Git LFS extension not found. Ensure that Git LFS is installed and available.")]
29 GitLfsNotFound,
30 #[error("Is Git LFS configured? Run `{}` to initialize Git LFS.", "git lfs install".green())]
31 GitLfsNotConfigured,
32 #[error(transparent)]
33 Other(#[from] which::Error),
34 #[error(
35 "Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
36 )]
37 TransportNotAllowed,
38}
39
40pub static GIT: LazyLock<Result<ProcessBuilder, GitError>> = LazyLock::new(|| {
45 let path = which::which("git").map_err(|err| match err {
46 which::Error::CannotFindBinaryPath => GitError::GitNotFound,
47 err => GitError::Other(err),
48 })?;
49
50 let mut cmd = ProcessBuilder::new(path);
51
52 cmd.env_remove(EnvVars::GIT_DIR)
59 .env_remove(EnvVars::GIT_WORK_TREE)
60 .env_remove(EnvVars::GIT_INDEX_FILE)
61 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
62 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
63 .env_remove(EnvVars::GIT_COMMON_DIR);
64
65 Ok(cmd)
66});
67
68enum RefspecStrategy {
70 All,
72 First,
74}
75
76#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
78enum ReferenceOrOid<'reference> {
79 Reference(&'reference GitReference),
81 Oid(GitOid),
83}
84
85impl ReferenceOrOid<'_> {
86 fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
88 let refkind = self.kind_str();
89 let result = match self {
90 Self::Reference(GitReference::Tag(s)) => {
95 repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
96 }
97
98 Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
100
101 Self::Reference(GitReference::BranchOrTag(s)) => repo
103 .rev_parse(&format!("origin/{s}^0"))
104 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
105
106 Self::Reference(GitReference::BranchOrTagOrCommit(s)) => repo
108 .rev_parse(&format!("origin/{s}^0"))
109 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
110 .or_else(|_| repo.rev_parse(&format!("{s}^0"))),
111
112 Self::Reference(GitReference::DefaultBranch) => {
114 repo.rev_parse("refs/remotes/origin/HEAD")
115 }
116
117 Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
119
120 Self::Oid(s) => repo.rev_parse(&format!("{s}^0")),
122 };
123
124 result.with_context(|| anyhow::format_err!("failed to find {refkind} `{self}`"))
125 }
126
127 fn kind_str(&self) -> &str {
129 match self {
130 Self::Reference(reference) => reference.kind_str(),
131 Self::Oid(_) => "commit",
132 }
133 }
134
135 fn as_rev(&self) -> &str {
137 match self {
138 Self::Reference(r) => r.as_rev(),
139 Self::Oid(rev) => rev.as_str(),
140 }
141 }
142}
143
144impl Display for ReferenceOrOid<'_> {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 match self {
147 Self::Reference(reference) => write!(f, "{reference}"),
148 Self::Oid(oid) => write!(f, "{oid}"),
149 }
150 }
151}
152
153#[derive(PartialEq, Clone, Debug)]
155pub(crate) struct GitRemote {
156 url: DisplaySafeUrl,
158}
159
160pub(crate) struct GitDatabase {
163 repo: GitRepository,
165 lfs_ready: Option<bool>,
167}
168
169pub(crate) struct GitCheckout {
171 revision: GitOid,
173 repo: GitRepository,
175 lfs_ready: Option<bool>,
177}
178
179pub(crate) struct GitRepository {
181 path: PathBuf,
183}
184
185impl GitRepository {
186 pub(crate) fn open(path: &Path) -> Result<Self> {
188 GIT.as_ref()
190 .cloned()?
191 .arg("rev-parse")
192 .cwd(path)
193 .exec_with_output()?;
194
195 Ok(Self {
196 path: path.to_path_buf(),
197 })
198 }
199
200 fn init(path: &Path) -> Result<Self> {
202 GIT.as_ref()
210 .cloned()?
211 .arg("init")
212 .cwd(path)
213 .exec_with_output()?;
214
215 Ok(Self {
216 path: path.to_path_buf(),
217 })
218 }
219
220 fn rev_parse(&self, refname: &str) -> Result<GitOid> {
222 let result = GIT
223 .as_ref()
224 .cloned()?
225 .arg("rev-parse")
226 .arg(refname)
227 .cwd(&self.path)
228 .exec_with_output()?;
229
230 let mut result = String::from_utf8(result.stdout)?;
231 result.truncate(result.trim_end().len());
232 Ok(result.parse()?)
233 }
234
235 #[instrument(skip_all, fields(path = %self.path.user_display(), refname = %refname))]
237 fn lfs_fsck_objects(&self, refname: &str) -> bool {
238 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
239 lfs.clone()
240 } else {
241 warn!("Git LFS is not available, skipping LFS fetch");
242 return false;
243 };
244
245 let result = cmd
247 .arg("fsck")
248 .arg("--objects")
249 .arg(refname)
250 .cwd(&self.path)
251 .exec_with_output();
252
253 match result {
254 Ok(_) => true,
255 Err(err) => {
256 let lfs_error = err.to_string();
257 if lfs_error.contains("unknown flag: --objects") {
258 warn_user_once!(
259 "Skipping Git LFS validation as Git LFS extension is outdated. \
260 Upgrade to `git-lfs>=3.0.2` or manually verify git-lfs objects were \
261 properly fetched after the current operation finishes."
262 );
263 true
264 } else {
265 debug!("Git LFS validation failed: {err}");
266 false
267 }
268 }
269 }
270 }
271}
272
273impl GitRemote {
274 pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
276 Self { url: url.clone() }
277 }
278
279 pub(crate) fn url(&self) -> &DisplaySafeUrl {
281 &self.url
282 }
283
284 pub(crate) fn checkout(
297 &self,
298 into: &Path,
299 db: Option<GitDatabase>,
300 reference: &GitReference,
301 locked_rev: Option<GitOid>,
302 disable_ssl: bool,
303 offline: bool,
304 with_lfs: bool,
305 ) -> Result<(GitDatabase, GitOid)> {
306 let reference = locked_rev
307 .map(ReferenceOrOid::Oid)
308 .unwrap_or(ReferenceOrOid::Reference(reference));
309 if let Some(mut db) = db {
310 fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
311 .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
312
313 let resolved_commit_hash = match locked_rev {
314 Some(rev) => db.contains(rev).then_some(rev),
315 None => reference.resolve(&db.repo).ok(),
316 };
317
318 if let Some(rev) = resolved_commit_hash {
319 if with_lfs {
320 let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
321 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
322 db = db.with_lfs_ready(Some(lfs_ready));
323 }
324 return Ok((db, rev));
325 }
326 }
327
328 match fs_err::remove_dir_all(into) {
332 Ok(()) => {}
333 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
334 Err(e) => return Err(e.into()),
335 }
336
337 fs_err::create_dir_all(into)?;
338 let mut repo = GitRepository::init(into)?;
339 fetch(&mut repo, &self.url, reference, disable_ssl, offline)
340 .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
341 let rev = match locked_rev {
342 Some(rev) => rev,
343 None => reference.resolve(&repo)?,
344 };
345 let lfs_ready = with_lfs
346 .then(|| {
347 fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
348 .with_context(|| format!("failed to fetch LFS objects at {rev}"))
349 })
350 .transpose()?;
351
352 Ok((GitDatabase { repo, lfs_ready }, rev))
353 }
354
355 #[expect(clippy::unused_self)]
357 pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
358 let repo = GitRepository::open(db_path)?;
359 Ok(GitDatabase {
360 repo,
361 lfs_ready: None,
362 })
363 }
364}
365
366impl GitDatabase {
367 pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
369 let checkout = match GitRepository::open(destination)
374 .ok()
375 .map(|repo| GitCheckout::new(rev, repo))
376 .filter(GitCheckout::is_fresh)
377 {
378 Some(co) => co.with_lfs_ready(self.lfs_ready),
379 None => GitCheckout::clone_into(destination, self, rev)?,
380 };
381 Ok(checkout)
382 }
383
384 pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
386 let output = GIT
387 .as_ref()
388 .cloned()?
389 .arg("rev-parse")
390 .arg("--short")
391 .arg(revision.as_str())
392 .cwd(&self.repo.path)
393 .exec_with_output()?;
394
395 let mut result = String::from_utf8(output.stdout)?;
396 result.truncate(result.trim_end().len());
397 Ok(result)
398 }
399
400 pub(crate) fn contains(&self, oid: GitOid) -> bool {
402 self.repo.rev_parse(&format!("{oid}^0")).is_ok()
403 }
404
405 pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
407 self.repo.lfs_fsck_objects(&format!("{oid}^0"))
408 }
409
410 #[must_use]
412 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
413 self.lfs_ready = lfs;
414 self
415 }
416}
417
418impl GitCheckout {
419 fn new(revision: GitOid, repo: GitRepository) -> Self {
424 Self {
425 revision,
426 repo,
427 lfs_ready: None,
428 }
429 }
430
431 fn clone_into(into: &Path, database: &GitDatabase, revision: GitOid) -> Result<Self> {
434 let dirname = into.parent().unwrap();
435 fs_err::create_dir_all(dirname)?;
436 match fs_err::remove_dir_all(into) {
437 Ok(()) => {}
438 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
439 Err(e) => return Err(e.into()),
440 }
441
442 let res = GIT
446 .as_ref()
447 .cloned()?
448 .arg("clone")
449 .arg("--local")
450 .arg(database.repo.path.simplified_display().to_string())
454 .arg(into.simplified_display().to_string())
455 .exec_with_output();
456
457 if let Err(e) = res {
458 debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
459
460 GIT.as_ref()
461 .cloned()?
462 .arg("clone")
463 .arg("--no-hardlinks")
464 .arg(database.repo.path.simplified_display().to_string())
465 .arg(into.simplified_display().to_string())
466 .exec_with_output()?;
467 }
468
469 let repo = GitRepository::open(into)?;
470 let checkout = Self::new(revision, repo);
471 let lfs_ready = checkout.reset(database.lfs_ready)?;
472 Ok(checkout.with_lfs_ready(lfs_ready))
473 }
474
475 fn is_fresh(&self) -> bool {
477 match self.repo.rev_parse("HEAD") {
478 Ok(id) if id == self.revision => {
479 self.repo.path.join(CHECKOUT_READY_LOCK).exists()
481 }
482 _ => false,
483 }
484 }
485
486 pub(crate) fn lfs_ready(&self) -> Option<bool> {
488 self.lfs_ready
489 }
490
491 #[must_use]
493 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
494 self.lfs_ready = lfs;
495 self
496 }
497
498 fn reset(&self, with_lfs: Option<bool>) -> Result<Option<bool>> {
512 let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
513 let _ = paths::remove_file(&ok_file);
514
515 let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
519 debug!("Reset {} to {}", self.repo.path.display(), self.revision);
520
521 GIT.as_ref()
523 .cloned()?
524 .arg("reset")
525 .arg("--hard")
526 .arg(self.revision.as_str())
527 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
528 .cwd(&self.repo.path)
529 .exec_with_output()?;
530
531 GIT.as_ref()
533 .cloned()?
534 .arg("submodule")
535 .arg("update")
536 .arg("--recursive")
537 .arg("--init")
538 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
539 .cwd(&self.repo.path)
540 .exec_with_output()
541 .map(drop)?;
542
543 let lfs_validation = match with_lfs {
546 None => None,
547 Some(false) => Some(false),
548 Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
549 };
550
551 if with_lfs.is_none() || lfs_validation == Some(true) {
555 paths::create(ok_file)?;
556 }
557
558 Ok(lfs_validation)
559 }
560}
561
562fn fetch(
571 repo: &mut GitRepository,
572 remote_url: &DisplaySafeUrl,
573 reference: ReferenceOrOid<'_>,
574 disable_ssl: bool,
575 offline: bool,
576) -> Result<()> {
577 let oid_to_fetch = if let ReferenceOrOid::Oid(rev) = reference {
578 let local_object = reference.resolve(repo).ok();
579 if let Some(local_object) = local_object {
580 if rev == local_object {
581 return Ok(());
582 }
583 }
584
585 Some(rev)
588 } else {
589 None
590 };
591
592 let mut refspecs = Vec::new();
595 let mut tags = false;
596 let mut refspec_strategy = RefspecStrategy::All;
597 match reference {
601 ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
604 refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
605 }
606
607 ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
608 refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
609 }
610
611 ReferenceOrOid::Reference(GitReference::BranchOrTag(branch_or_tag)) => {
612 refspecs.push(format!(
613 "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
614 ));
615 refspecs.push(format!(
616 "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
617 ));
618 refspec_strategy = RefspecStrategy::First;
619 }
620
621 ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
624 if let Some(oid_to_fetch) =
628 oid_to_fetch.filter(|oid| is_short_hash_of(branch_or_tag_or_commit, *oid))
629 {
630 refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
631 } else {
632 refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
636 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
637 tags = true;
638 }
639 }
640
641 ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
642 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
643 }
644
645 ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
646 refspecs.push(format!("+{rev}:{rev}"));
647 }
648
649 ReferenceOrOid::Oid(rev) => {
650 refspecs.push(format!("+{rev}:refs/commit/{rev}"));
651 }
652 }
653
654 debug!("Performing a Git fetch for: {remote_url}");
655 let result = match refspec_strategy {
656 RefspecStrategy::All => fetch_with_cli(
657 repo,
658 remote_url,
659 refspecs.as_slice(),
660 tags,
661 disable_ssl,
662 offline,
663 ),
664 RefspecStrategy::First => {
665 let mut errors = refspecs
667 .iter()
668 .map_while(|refspec| {
669 let fetch_result = fetch_with_cli(
670 repo,
671 remote_url,
672 std::slice::from_ref(refspec),
673 tags,
674 disable_ssl,
675 offline,
676 );
677
678 match fetch_result {
680 Err(ref err) => {
681 debug!("Failed to fetch refspec `{refspec}`: {err}");
682 Some(fetch_result)
683 }
684 Ok(()) => None,
685 }
686 })
687 .collect::<Vec<_>>();
688
689 if errors.len() == refspecs.len() {
690 if let Some(result) = errors.pop() {
691 result
693 } else {
694 Ok(())
696 }
697 } else {
698 Ok(())
699 }
700 }
701 };
702 match reference {
703 ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
705 _ => result.with_context(|| {
706 format!(
707 "failed to fetch {} `{}`",
708 reference.kind_str(),
709 reference.as_rev()
710 )
711 }),
712 }
713}
714
715fn fetch_with_cli(
717 repo: &mut GitRepository,
718 url: &DisplaySafeUrl,
719 refspecs: &[String],
720 tags: bool,
721 disable_ssl: bool,
722 offline: bool,
723) -> Result<()> {
724 let mut cmd = GIT.as_ref().cloned()?;
725 cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
729
730 cmd.arg("fetch");
731 if tags {
732 cmd.arg("--tags");
733 }
734 if disable_ssl {
735 debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
736 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
737 }
738 if offline {
739 debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
740 cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
741 }
742 cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
745 .args(refspecs)
746 .cwd(&repo.path);
747
748 cmd.exec_with_output().map_err(|err| {
752 let msg = err.to_string();
753 if msg.contains("transport '") && msg.contains("' not allowed") && offline {
754 return GitError::TransportNotAllowed.into();
755 }
756 err
757 })?;
758
759 Ok(())
760}
761
762pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
773 if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
774 return Err(anyhow!("Git LFS extension has been forcefully disabled."));
775 }
776
777 let mut cmd = GIT.as_ref()?.clone();
778 cmd.arg("lfs");
779
780 cmd.clone().arg("version").exec_with_output()?;
782 Ok(cmd)
783});
784
785fn fetch_lfs(
787 repo: &mut GitRepository,
788 url: &DisplaySafeUrl,
789 revision: &GitOid,
790 disable_ssl: bool,
791) -> Result<bool> {
792 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
793 debug!("Fetching Git LFS objects");
794 lfs.clone()
795 } else {
796 warn!("Git LFS is not available, skipping LFS fetch");
798 return Ok(false);
799 };
800
801 if disable_ssl {
802 debug!("Disabling SSL verification for Git LFS");
803 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
804 }
805
806 cmd.arg("fetch")
807 .arg(url.as_str())
808 .arg(revision.as_str())
809 .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
812 .cwd(&repo.path);
813
814 cmd.exec_with_output()?;
815
816 let validation_result = repo.lfs_fsck_objects(revision.as_str());
824
825 Ok(validation_result)
826}
827
828fn is_short_hash_of(rev: &str, oid: GitOid) -> bool {
830 let long_hash = oid.to_string();
831 match long_hash.get(..rev.len()) {
832 Some(truncated_long_hash) => truncated_long_hash.eq_ignore_ascii_case(rev),
833 None => false,
834 }
835}