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<PathBuf, GitError>> = LazyLock::new(|| {
42 which::which("git").map_err(|err| match err {
43 which::Error::CannotFindBinaryPath => GitError::GitNotFound,
44 err => GitError::Other(err),
45 })
46});
47
48enum RefspecStrategy {
50 All,
52 First,
54}
55
56#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
58enum ReferenceOrOid<'reference> {
59 Reference(&'reference GitReference),
61 Oid(GitOid),
63}
64
65impl ReferenceOrOid<'_> {
66 fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
68 let refkind = self.kind_str();
69 let result = match self {
70 Self::Reference(GitReference::Tag(s)) => {
75 repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
76 }
77
78 Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
80
81 Self::Reference(GitReference::BranchOrTag(s)) => repo
83 .rev_parse(&format!("origin/{s}^0"))
84 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
85
86 Self::Reference(GitReference::BranchOrTagOrCommit(s)) => repo
88 .rev_parse(&format!("origin/{s}^0"))
89 .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
90 .or_else(|_| repo.rev_parse(&format!("{s}^0"))),
91
92 Self::Reference(GitReference::DefaultBranch) => {
94 repo.rev_parse("refs/remotes/origin/HEAD")
95 }
96
97 Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
99
100 Self::Oid(s) => repo.rev_parse(&format!("{s}^0")),
102 };
103
104 result.with_context(|| anyhow::format_err!("failed to find {refkind} `{self}`"))
105 }
106
107 fn kind_str(&self) -> &str {
109 match self {
110 Self::Reference(reference) => reference.kind_str(),
111 Self::Oid(_) => "commit",
112 }
113 }
114
115 fn as_rev(&self) -> &str {
117 match self {
118 Self::Reference(r) => r.as_rev(),
119 Self::Oid(rev) => rev.as_str(),
120 }
121 }
122}
123
124impl Display for ReferenceOrOid<'_> {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 match self {
127 Self::Reference(reference) => write!(f, "{reference}"),
128 Self::Oid(oid) => write!(f, "{oid}"),
129 }
130 }
131}
132
133#[derive(PartialEq, Clone, Debug)]
135pub(crate) struct GitRemote {
136 url: DisplaySafeUrl,
138}
139
140pub(crate) struct GitDatabase {
143 repo: GitRepository,
145 lfs_ready: Option<bool>,
147}
148
149pub(crate) struct GitCheckout {
151 revision: GitOid,
153 repo: GitRepository,
155 lfs_ready: Option<bool>,
157}
158
159pub(crate) struct GitRepository {
161 path: PathBuf,
163}
164
165impl GitRepository {
166 pub(crate) fn open(path: &Path) -> Result<Self> {
168 ProcessBuilder::new(GIT.as_ref()?)
170 .arg("rev-parse")
171 .cwd(path)
172 .exec_with_output()?;
173
174 Ok(Self {
175 path: path.to_path_buf(),
176 })
177 }
178
179 fn init(path: &Path) -> Result<Self> {
181 ProcessBuilder::new(GIT.as_ref()?)
189 .arg("init")
190 .cwd(path)
191 .exec_with_output()?;
192
193 Ok(Self {
194 path: path.to_path_buf(),
195 })
196 }
197
198 fn rev_parse(&self, refname: &str) -> Result<GitOid> {
200 let result = ProcessBuilder::new(GIT.as_ref()?)
201 .arg("rev-parse")
202 .arg(refname)
203 .cwd(&self.path)
204 .exec_with_output()?;
205
206 let mut result = String::from_utf8(result.stdout)?;
207 result.truncate(result.trim_end().len());
208 Ok(result.parse()?)
209 }
210
211 #[instrument(skip_all, fields(path = %self.path.user_display(), refname = %refname))]
213 fn lfs_fsck_objects(&self, refname: &str) -> bool {
214 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
215 lfs.clone()
216 } else {
217 warn!("Git LFS is not available, skipping LFS fetch");
218 return false;
219 };
220
221 let result = cmd
223 .arg("fsck")
224 .arg("--objects")
225 .arg(refname)
226 .cwd(&self.path)
227 .exec_with_output();
228
229 match result {
230 Ok(_) => true,
231 Err(err) => {
232 let lfs_error = err.to_string();
233 if lfs_error.contains("unknown flag: --objects") {
234 warn_user_once!(
235 "Skipping Git LFS validation as Git LFS extension is outdated. \
236 Upgrade to `git-lfs>=3.0.2` or manually verify git-lfs objects were \
237 properly fetched after the current operation finishes."
238 );
239 true
240 } else {
241 debug!("Git LFS validation failed: {err}");
242 false
243 }
244 }
245 }
246 }
247}
248
249impl GitRemote {
250 pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
252 Self { url: url.clone() }
253 }
254
255 pub(crate) fn url(&self) -> &DisplaySafeUrl {
257 &self.url
258 }
259
260 pub(crate) fn checkout(
273 &self,
274 into: &Path,
275 db: Option<GitDatabase>,
276 reference: &GitReference,
277 locked_rev: Option<GitOid>,
278 disable_ssl: bool,
279 offline: bool,
280 with_lfs: bool,
281 ) -> Result<(GitDatabase, GitOid)> {
282 let reference = locked_rev
283 .map(ReferenceOrOid::Oid)
284 .unwrap_or(ReferenceOrOid::Reference(reference));
285 if let Some(mut db) = db {
286 fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
287 .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
288
289 let resolved_commit_hash = match locked_rev {
290 Some(rev) => db.contains(rev).then_some(rev),
291 None => reference.resolve(&db.repo).ok(),
292 };
293
294 if let Some(rev) = resolved_commit_hash {
295 if with_lfs {
296 let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
297 .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
298 db = db.with_lfs_ready(Some(lfs_ready));
299 }
300 return Ok((db, rev));
301 }
302 }
303
304 match fs_err::remove_dir_all(into) {
308 Ok(()) => {}
309 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
310 Err(e) => return Err(e.into()),
311 }
312
313 fs_err::create_dir_all(into)?;
314 let mut repo = GitRepository::init(into)?;
315 fetch(&mut repo, &self.url, reference, disable_ssl, offline)
316 .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
317 let rev = match locked_rev {
318 Some(rev) => rev,
319 None => reference.resolve(&repo)?,
320 };
321 let lfs_ready = with_lfs
322 .then(|| {
323 fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
324 .with_context(|| format!("failed to fetch LFS objects at {rev}"))
325 })
326 .transpose()?;
327
328 Ok((GitDatabase { repo, lfs_ready }, rev))
329 }
330
331 #[expect(clippy::unused_self)]
333 pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
334 let repo = GitRepository::open(db_path)?;
335 Ok(GitDatabase {
336 repo,
337 lfs_ready: None,
338 })
339 }
340}
341
342impl GitDatabase {
343 pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
345 let checkout = match GitRepository::open(destination)
350 .ok()
351 .map(|repo| GitCheckout::new(rev, repo))
352 .filter(GitCheckout::is_fresh)
353 {
354 Some(co) => co.with_lfs_ready(self.lfs_ready),
355 None => GitCheckout::clone_into(destination, self, rev)?,
356 };
357 Ok(checkout)
358 }
359
360 pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
362 let output = ProcessBuilder::new(GIT.as_ref()?)
363 .arg("rev-parse")
364 .arg("--short")
365 .arg(revision.as_str())
366 .cwd(&self.repo.path)
367 .exec_with_output()?;
368
369 let mut result = String::from_utf8(output.stdout)?;
370 result.truncate(result.trim_end().len());
371 Ok(result)
372 }
373
374 pub(crate) fn contains(&self, oid: GitOid) -> bool {
376 self.repo.rev_parse(&format!("{oid}^0")).is_ok()
377 }
378
379 pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
381 self.repo.lfs_fsck_objects(&format!("{oid}^0"))
382 }
383
384 #[must_use]
386 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
387 self.lfs_ready = lfs;
388 self
389 }
390}
391
392impl GitCheckout {
393 fn new(revision: GitOid, repo: GitRepository) -> Self {
398 Self {
399 revision,
400 repo,
401 lfs_ready: None,
402 }
403 }
404
405 fn clone_into(into: &Path, database: &GitDatabase, revision: GitOid) -> Result<Self> {
408 let dirname = into.parent().unwrap();
409 fs_err::create_dir_all(dirname)?;
410 match fs_err::remove_dir_all(into) {
411 Ok(()) => {}
412 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
413 Err(e) => return Err(e.into()),
414 }
415
416 let res = ProcessBuilder::new(GIT.as_ref()?)
420 .arg("clone")
421 .arg("--local")
422 .arg(database.repo.path.simplified_display().to_string())
426 .arg(into.simplified_display().to_string())
427 .exec_with_output();
428
429 if let Err(e) = res {
430 debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
431
432 ProcessBuilder::new(GIT.as_ref()?)
433 .arg("clone")
434 .arg("--no-hardlinks")
435 .arg(database.repo.path.simplified_display().to_string())
436 .arg(into.simplified_display().to_string())
437 .exec_with_output()?;
438 }
439
440 let repo = GitRepository::open(into)?;
441 let checkout = Self::new(revision, repo);
442 let lfs_ready = checkout.reset(database.lfs_ready)?;
443 Ok(checkout.with_lfs_ready(lfs_ready))
444 }
445
446 fn is_fresh(&self) -> bool {
448 match self.repo.rev_parse("HEAD") {
449 Ok(id) if id == self.revision => {
450 self.repo.path.join(CHECKOUT_READY_LOCK).exists()
452 }
453 _ => false,
454 }
455 }
456
457 pub(crate) fn lfs_ready(&self) -> Option<bool> {
459 self.lfs_ready
460 }
461
462 #[must_use]
464 pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
465 self.lfs_ready = lfs;
466 self
467 }
468
469 fn reset(&self, with_lfs: Option<bool>) -> Result<Option<bool>> {
483 let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
484 let _ = paths::remove_file(&ok_file);
485
486 let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
490 debug!("Reset {} to {}", self.repo.path.display(), self.revision);
491
492 ProcessBuilder::new(GIT.as_ref()?)
494 .arg("reset")
495 .arg("--hard")
496 .arg(self.revision.as_str())
497 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
498 .cwd(&self.repo.path)
499 .exec_with_output()?;
500
501 ProcessBuilder::new(GIT.as_ref()?)
503 .arg("submodule")
504 .arg("update")
505 .arg("--recursive")
506 .arg("--init")
507 .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
508 .cwd(&self.repo.path)
509 .exec_with_output()
510 .map(drop)?;
511
512 let lfs_validation = match with_lfs {
515 None => None,
516 Some(false) => Some(false),
517 Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
518 };
519
520 if with_lfs.is_none() || lfs_validation == Some(true) {
524 paths::create(ok_file)?;
525 }
526
527 Ok(lfs_validation)
528 }
529}
530
531fn fetch(
540 repo: &mut GitRepository,
541 remote_url: &DisplaySafeUrl,
542 reference: ReferenceOrOid<'_>,
543 disable_ssl: bool,
544 offline: bool,
545) -> Result<()> {
546 let oid_to_fetch = if let ReferenceOrOid::Oid(rev) = reference {
547 let local_object = reference.resolve(repo).ok();
548 if let Some(local_object) = local_object {
549 if rev == local_object {
550 return Ok(());
551 }
552 }
553
554 Some(rev)
557 } else {
558 None
559 };
560
561 let mut refspecs = Vec::new();
564 let mut tags = false;
565 let mut refspec_strategy = RefspecStrategy::All;
566 match reference {
570 ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
573 refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
574 }
575
576 ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
577 refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
578 }
579
580 ReferenceOrOid::Reference(GitReference::BranchOrTag(branch_or_tag)) => {
581 refspecs.push(format!(
582 "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
583 ));
584 refspecs.push(format!(
585 "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
586 ));
587 refspec_strategy = RefspecStrategy::First;
588 }
589
590 ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
593 if let Some(oid_to_fetch) =
597 oid_to_fetch.filter(|oid| is_short_hash_of(branch_or_tag_or_commit, *oid))
598 {
599 refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
600 } else {
601 refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
605 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
606 tags = true;
607 }
608 }
609
610 ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
611 refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
612 }
613
614 ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
615 refspecs.push(format!("+{rev}:{rev}"));
616 }
617
618 ReferenceOrOid::Oid(rev) => {
619 refspecs.push(format!("+{rev}:refs/commit/{rev}"));
620 }
621 }
622
623 debug!("Performing a Git fetch for: {remote_url}");
624 let result = match refspec_strategy {
625 RefspecStrategy::All => fetch_with_cli(
626 repo,
627 remote_url,
628 refspecs.as_slice(),
629 tags,
630 disable_ssl,
631 offline,
632 ),
633 RefspecStrategy::First => {
634 let mut errors = refspecs
636 .iter()
637 .map_while(|refspec| {
638 let fetch_result = fetch_with_cli(
639 repo,
640 remote_url,
641 std::slice::from_ref(refspec),
642 tags,
643 disable_ssl,
644 offline,
645 );
646
647 match fetch_result {
649 Err(ref err) => {
650 debug!("Failed to fetch refspec `{refspec}`: {err}");
651 Some(fetch_result)
652 }
653 Ok(()) => None,
654 }
655 })
656 .collect::<Vec<_>>();
657
658 if errors.len() == refspecs.len() {
659 if let Some(result) = errors.pop() {
660 result
662 } else {
663 Ok(())
665 }
666 } else {
667 Ok(())
668 }
669 }
670 };
671 match reference {
672 ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
674 _ => result.with_context(|| {
675 format!(
676 "failed to fetch {} `{}`",
677 reference.kind_str(),
678 reference.as_rev()
679 )
680 }),
681 }
682}
683
684fn fetch_with_cli(
686 repo: &mut GitRepository,
687 url: &DisplaySafeUrl,
688 refspecs: &[String],
689 tags: bool,
690 disable_ssl: bool,
691 offline: bool,
692) -> Result<()> {
693 let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
694 cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
698
699 cmd.arg("fetch");
700 if tags {
701 cmd.arg("--tags");
702 }
703 if disable_ssl {
704 debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
705 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
706 }
707 if offline {
708 debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
709 cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
710 }
711 cmd.arg("--force") .arg("--update-head-ok") .arg(url.as_str())
714 .args(refspecs)
715 .env_remove(EnvVars::GIT_DIR)
720 .env_remove(EnvVars::GIT_WORK_TREE)
723 .env_remove(EnvVars::GIT_INDEX_FILE)
724 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
725 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
726 .cwd(&repo.path);
727
728 cmd.exec_with_output().map_err(|err| {
732 let msg = err.to_string();
733 if msg.contains("transport '") && msg.contains("' not allowed") && offline {
734 return GitError::TransportNotAllowed.into();
735 }
736 err
737 })?;
738
739 Ok(())
740}
741
742pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
753 if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
754 return Err(anyhow!("Git LFS extension has been forcefully disabled."));
755 }
756
757 let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
758 cmd.arg("lfs");
759
760 cmd.clone().arg("version").exec_with_output()?;
762 Ok(cmd)
763});
764
765fn fetch_lfs(
767 repo: &mut GitRepository,
768 url: &DisplaySafeUrl,
769 revision: &GitOid,
770 disable_ssl: bool,
771) -> Result<bool> {
772 let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
773 debug!("Fetching Git LFS objects");
774 lfs.clone()
775 } else {
776 warn!("Git LFS is not available, skipping LFS fetch");
778 return Ok(false);
779 };
780
781 if disable_ssl {
782 debug!("Disabling SSL verification for Git LFS");
783 cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
784 }
785
786 cmd.arg("fetch")
787 .arg(url.as_str())
788 .arg(revision.as_str())
789 .env_remove(EnvVars::GIT_DIR)
791 .env_remove(EnvVars::GIT_WORK_TREE)
792 .env_remove(EnvVars::GIT_INDEX_FILE)
793 .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
794 .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
795 .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
798 .cwd(&repo.path);
799
800 cmd.exec_with_output()?;
801
802 let validation_result = repo.lfs_fsck_objects(revision.as_str());
810
811 Ok(validation_result)
812}
813
814fn is_short_hash_of(rev: &str, oid: GitOid) -> bool {
816 let long_hash = oid.to_string();
817 match long_hash.get(..rev.len()) {
818 Some(truncated_long_hash) => truncated_long_hash.eq_ignore_ascii_case(rev),
819 None => false,
820 }
821}