uv_git/
git.rs

1//! Git support is derived from Cargo's implementation.
2//! Cargo is dual-licensed under either Apache 2.0 or MIT, at the user's choice.
3//! Source: <https://github.com/rust-lang/cargo/blob/23eb492cf920ce051abfc56bbaf838514dc8365c/src/cargo/sources/git/utils.rs>
4use 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
21/// A file indicates that if present, `git reset` has been done and a repo
22/// checkout is ready to go. See [`GitCheckout::reset`] for why we need this.
23const 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
41/// A global cache of the result of `which git`.
42pub 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
49/// Strategy when fetching refspecs for a [`GitReference`]
50enum RefspecStrategy {
51    /// All refspecs should be fetched, if any fail then the fetch will fail.
52    All,
53    /// Stop after the first successful fetch, if none succeed then the fetch will fail.
54    First,
55}
56
57/// A Git reference (like a tag or branch) or a specific commit.
58#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
59enum ReferenceOrOid<'reference> {
60    /// A Git reference, like a tag or branch.
61    Reference(&'reference GitReference),
62    /// A specific commit.
63    Oid(GitOid),
64}
65
66impl ReferenceOrOid<'_> {
67    /// Resolves the [`ReferenceOrOid`] to an object ID with objects the `repo` currently has.
68    fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
69        let refkind = self.kind_str();
70        let result = match self {
71            // Resolve the commit pointed to by the tag.
72            //
73            // `^0` recursively peels away from the revision to the underlying commit object.
74            // This also verifies that the tag indeed refers to a commit.
75            Self::Reference(GitReference::Tag(s)) => {
76                repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
77            }
78
79            // Resolve the commit pointed to by the branch.
80            Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
81
82            // Attempt to resolve the branch, then the tag.
83            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            // Attempt to resolve the branch, then the tag, then the commit.
88            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            // We'll be using the HEAD commit.
94            Self::Reference(GitReference::DefaultBranch) => {
95                repo.rev_parse("refs/remotes/origin/HEAD")
96            }
97
98            // Resolve a named reference.
99            Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
100
101            // Resolve a specific commit.
102            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    /// Returns the kind of this [`ReferenceOrOid`].
109    fn kind_str(&self) -> &str {
110        match self {
111            Self::Reference(reference) => reference.kind_str(),
112            Self::Oid(_) => "commit",
113        }
114    }
115
116    /// Converts the [`ReferenceOrOid`] to a `str` that can be used as a revision.
117    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/// A remote repository. It gets cloned into a local [`GitDatabase`].
135#[derive(PartialEq, Clone, Debug)]
136pub(crate) struct GitRemote {
137    /// URL to a remote repository.
138    url: DisplaySafeUrl,
139}
140
141/// A local clone of a remote repository's database. Multiple [`GitCheckout`]s
142/// can be cloned from a single [`GitDatabase`].
143pub(crate) struct GitDatabase {
144    /// Underlying Git repository instance for this database.
145    repo: GitRepository,
146    /// Git LFS artifacts have been initialized (if requested).
147    lfs_ready: Option<bool>,
148}
149
150/// A local checkout of a particular revision from a [`GitRepository`].
151pub(crate) struct GitCheckout {
152    /// The git revision this checkout is for.
153    revision: GitOid,
154    /// Underlying Git repository instance for this checkout.
155    repo: GitRepository,
156    /// Git LFS artifacts have been initialized (if requested).
157    lfs_ready: Option<bool>,
158}
159
160/// A local Git repository.
161pub(crate) struct GitRepository {
162    /// Path to the underlying Git repository on the local filesystem.
163    path: PathBuf,
164}
165
166impl GitRepository {
167    /// Opens an existing Git repository at `path`.
168    pub(crate) fn open(path: &Path) -> Result<Self> {
169        // Make sure there is a Git repository at the specified path.
170        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    /// Initializes a Git repository at `path`.
181    fn init(path: &Path) -> Result<Self> {
182        // TODO(ibraheem): see if this still necessary now that we no longer use libgit2
183        // Skip anything related to templates, they just call all sorts of issues as
184        // we really don't want to use them yet they insist on being used. See #6240
185        // for an example issue that comes up.
186        // opts.external_template(false);
187
188        // Initialize the repository.
189        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    /// Parses the object ID of the given `refname`.
200    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    /// Verifies LFS artifacts have been initialized for a given `refname`.
213    #[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        // Requires Git LFS 3.x (2021 release)
223        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    /// Creates an instance for a remote repository URL.
252    pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
253        Self { url: url.clone() }
254    }
255
256    /// Gets the remote repository URL.
257    pub(crate) fn url(&self) -> &DisplaySafeUrl {
258        &self.url
259    }
260
261    /// Fetches and checkouts to a reference or a revision from this remote
262    /// into a local path.
263    ///
264    /// This ensures that it gets the up-to-date commit when a named reference
265    /// is given (tag, branch, refs/*). Thus, network connection is involved.
266    ///
267    /// When `locked_rev` is provided, it takes precedence over `reference`.
268    ///
269    /// If we have a previous instance of [`GitDatabase`] then fetch into that
270    /// if we can. If that can successfully load our revision then we've
271    /// populated the database with the latest version of `reference`, so
272    /// return that database and the rev we resolve to.
273    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        // Otherwise start from scratch to handle corrupt git repositories.
306        // After our fetch (which is interpreted as a clone now) we do the same
307        // resolution to figure out what we cloned.
308        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    /// Creates a [`GitDatabase`] of this remote at `db_path`.
333    #[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    /// Checkouts to a revision at `destination` from this database.
345    pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
346        // If the existing checkout exists, and it is fresh, use it.
347        // A non-fresh checkout can happen if the checkout operation was
348        // interrupted. In that case, the checkout gets deleted and a new
349        // clone is created.
350        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    /// Get a short OID for a `revision`, usually 7 chars or more if ambiguous.
362    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    /// Checks if `oid` resolves to a commit in this database.
376    pub(crate) fn contains(&self, oid: GitOid) -> bool {
377        self.repo.rev_parse(&format!("{oid}^0")).is_ok()
378    }
379
380    /// Checks if `oid` contains necessary LFS artifacts in this database.
381    pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
382        self.repo.lfs_fsck_objects(&format!("{oid}^0"))
383    }
384
385    /// Set the Git LFS validation state (if any).
386    #[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    /// Creates an instance of [`GitCheckout`]. This doesn't imply the checkout
395    /// is done. Use [`GitCheckout::is_fresh`] to check.
396    ///
397    /// * The `repo` will be the checked out Git repository.
398    fn new(revision: GitOid, repo: GitRepository) -> Self {
399        Self {
400            revision,
401            repo,
402            lfs_ready: None,
403        }
404    }
405
406    /// Clone a repo for a `revision` into a local path from a `database`.
407    /// This is a filesystem-to-filesystem clone.
408    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        // Perform a local clone of the repository, which will attempt to use
418        // hardlinks to set up the repository. This should speed up the clone operation
419        // quite a bit if it works.
420        let res = ProcessBuilder::new(GIT.as_ref()?)
421            .arg("clone")
422            .arg("--local")
423            // Make sure to pass the local file path and not a file://... url. If given a url,
424            // Git treats the repository as a remote origin and gets confused because we don't
425            // have a HEAD checked out.
426            .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    /// Checks if the `HEAD` of this checkout points to the expected revision.
448    fn is_fresh(&self) -> bool {
449        match self.repo.rev_parse("HEAD") {
450            Ok(id) if id == self.revision => {
451                // See comments in reset() for why we check this
452                self.repo.path.join(CHECKOUT_READY_LOCK).exists()
453            }
454            _ => false,
455        }
456    }
457
458    /// Indicates Git LFS artifacts have been initialized (when requested).
459    pub(crate) fn lfs_ready(&self) -> Option<bool> {
460        self.lfs_ready
461    }
462
463    /// Set the Git LFS validation state (if any).
464    #[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    /// This performs `git reset --hard` to the revision of this checkout, with
471    /// additional interrupt protection by a dummy file [`CHECKOUT_READY_LOCK`].
472    ///
473    /// If we're interrupted while performing a `git reset` (e.g., we die
474    /// because of a signal) uv needs to be sure to try to check out this
475    /// repo again on the next go-round.
476    ///
477    /// To enable this we have a dummy file in our checkout, [`.ok`],
478    /// which if present means that the repo has been successfully reset and is
479    /// ready to go. Hence, if we start to do a reset, we make sure this file
480    /// *doesn't* exist, and then once we're done we create the file.
481    ///
482    /// [`.ok`]: CHECKOUT_READY_LOCK
483    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        // We want to skip smudge if lfs was disabled for the repository
488        // as smudge filters can trigger on a reset even if lfs artifacts
489        // were not originally "fetched".
490        let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
491        debug!("Reset {} to {}", self.repo.path.display(), self.revision);
492
493        // Perform the hard reset.
494        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        // Update submodules (`git submodule update --recursive`).
503        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        // Validate Git LFS objects (if needed) after the reset.
514        // See `fetch_lfs` why we do this.
515        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        // The .ok file should be written when the reset is successful.
522        // When Git LFS is enabled, the objects must also be fetched and
523        // validated successfully as part of the corresponding db.
524        if with_lfs.is_none() || lfs_validation == Some(true) {
525            paths::create(ok_file)?;
526        }
527
528        Ok(lfs_validation)
529    }
530}
531
532/// Attempts to fetch the given git `reference` for a Git repository.
533///
534/// This is the main entry for git clone/fetch. It does the following:
535///
536/// * Turns [`GitReference`] into refspecs accordingly.
537/// * Dispatches `git fetch` using the git CLI.
538///
539/// The `remote_url` argument is the git remote URL where we want to fetch from.
540fn 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        // If we know the reference is a full commit hash, we can just return it without
556        // querying GitHub.
557        Some(rev)
558    } else {
559        None
560    };
561
562    // Translate the reference desired here into an actual list of refspecs
563    // which need to get fetched. Additionally record if we're fetching tags.
564    let mut refspecs = Vec::new();
565    let mut tags = false;
566    let mut refspec_strategy = RefspecStrategy::All;
567    // The `+` symbol on the refspec means to allow a forced (fast-forward)
568    // update which is needed if there is ever a force push that requires a
569    // fast-forward.
570    match reference {
571        // For branches and tags we can fetch simply one reference and copy it
572        // locally, no need to fetch other branches/tags.
573        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        // For ambiguous references, we can fetch the exact commit (if known); otherwise,
592        // we fetch all branches and tags.
593        ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
594            // The `oid_to_fetch` is the exact commit we want to fetch. But it could be the exact
595            // commit of a branch or tag. We should only fetch it directly if it's the exact commit
596            // of a short commit hash.
597            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                // We don't know what the rev will point to. To handle this
603                // situation we fetch all branches and tags, and then we pray
604                // it's somewhere in there.
605                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            // Try each refspec
636            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                    // Stop after the first success and log failures
649                    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                    // Use the last error for the message
662                    result
663                } else {
664                    // Can only occur if there were no refspecs to fetch
665                    Ok(())
666                }
667            } else {
668                Ok(())
669            }
670        }
671    };
672    match reference {
673        // With the default branch, adding context is confusing
674        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
685/// Attempts to use `git` CLI installed on the system to fetch a repository.
686fn 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    // Disable interactive prompts in the terminal, as they'll be erased by the progress bar
696    // animation and the process will "hang". Interactive prompts via the GUI like `SSH_ASKPASS`
697    // are still usable.
698    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") // handle force pushes
713        .arg("--update-head-ok") // see discussion in #2078
714        .arg(url.as_str())
715        .args(refspecs)
716        // If cargo is run by git (for example, the `exec` command in `git
717        // rebase`), the GIT_DIR is set by git and will point to the wrong
718        // location (this takes precedence over the cwd). Make sure this is
719        // unset so git will look at cwd for the repo.
720        .env_remove(EnvVars::GIT_DIR)
721        // The reset of these may not be necessary, but I'm including them
722        // just to be extra paranoid and avoid any issues.
723        .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    // We capture the output to avoid streaming it to the user's console during clones.
730    // The required `on...line` callbacks currently do nothing.
731    // The output appears to be included in error messages by default.
732    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
743/// A global cache of the `git lfs` command.
744///
745/// Returns an error if Git LFS isn't available.
746/// Caching the command allows us to only check if LFS is installed once.
747///
748/// We also support a helper private environment variable to allow
749/// controlling the LFS extension from being loaded for testing purposes.
750/// Once installed, Git will always load `git-lfs` as a built-in alias
751/// which takes priority over loading from `PATH` which prevents us
752/// from shadowing the extension with other means.
753pub 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    // Run a simple command to verify LFS is installed
762    cmd.clone().arg("version").exec_with_output()?;
763    Ok(cmd)
764});
765
766/// Attempts to use `git-lfs` CLI to fetch required LFS objects for a given revision.
767fn 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        // Since this feature is opt-in, warn if not available
778        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        // These variables are unset for the same reason as in `fetch_with_cli`.
791        .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        // We should not support requesting LFS artifacts with skip smudge being set.
797        // While this may not be necessary, it's added to avoid any potential future issues.
798        .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
799        .cwd(&repo.path);
800
801    cmd.exec_with_output()?;
802
803    // We now validate the Git LFS objects explicitly (if supported). This is
804    // needed to avoid issues with Git LFS not being installed or configured
805    // on the system and giving the wrong impression to the user that Git LFS
806    // objects were initialized correctly when installation finishes.
807    // We may want to allow the user to skip validation in the future via
808    // UV_GIT_LFS_NO_VALIDATION environment variable on rare cases where
809    // validation costs outweigh the benefit.
810    let validation_result = repo.lfs_fsck_objects(revision.as_str());
811
812    Ok(validation_result)
813}
814
815/// Whether `rev` is a shorter hash of `oid`.
816fn 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}