Skip to main content

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