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` as a command
41///
42/// Caching the command allows us to avoid needing to remove environment
43/// variables everywhere.
44pub 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    // Certain git environment variables never make sense to inherit because
53    // they affect what the current command will act on.
54
55    // This can cause problems if for example uv is ran by git (for example, the
56    // `exec` command in `git rebase`), the GIT_DIR is set by git and will point
57    // to the wrong location (this takes precedence over the cwd).
58    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
68/// Strategy when fetching refspecs for a [`GitReference`]
69enum RefspecStrategy {
70    /// All refspecs should be fetched, if any fail then the fetch will fail.
71    All,
72    /// Stop after the first successful fetch, if none succeed then the fetch will fail.
73    First,
74}
75
76/// A Git reference (like a tag or branch) or a specific commit.
77#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
78enum ReferenceOrOid<'reference> {
79    /// A Git reference, like a tag or branch.
80    Reference(&'reference GitReference),
81    /// A specific commit.
82    Oid(GitOid),
83}
84
85impl ReferenceOrOid<'_> {
86    /// Resolves the [`ReferenceOrOid`] to an object ID with objects the `repo` currently has.
87    fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
88        let refkind = self.kind_str();
89        let result = match self {
90            // Resolve the commit pointed to by the tag.
91            //
92            // `^0` recursively peels away from the revision to the underlying commit object.
93            // This also verifies that the tag indeed refers to a commit.
94            Self::Reference(GitReference::Tag(s)) => {
95                repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
96            }
97
98            // Resolve the commit pointed to by the branch.
99            Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
100
101            // Attempt to resolve the branch, then the tag.
102            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            // Attempt to resolve the branch, then the tag, then the commit.
107            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            // We'll be using the HEAD commit.
113            Self::Reference(GitReference::DefaultBranch) => {
114                repo.rev_parse("refs/remotes/origin/HEAD")
115            }
116
117            // Resolve a named reference.
118            Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
119
120            // Resolve a specific commit.
121            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    /// Returns the kind of this [`ReferenceOrOid`].
128    fn kind_str(&self) -> &str {
129        match self {
130            Self::Reference(reference) => reference.kind_str(),
131            Self::Oid(_) => "commit",
132        }
133    }
134
135    /// Converts the [`ReferenceOrOid`] to a `str` that can be used as a revision.
136    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/// A remote repository. It gets cloned into a local [`GitDatabase`].
154#[derive(PartialEq, Clone, Debug)]
155pub(crate) struct GitRemote {
156    /// URL to a remote repository.
157    url: DisplaySafeUrl,
158}
159
160/// A local clone of a remote repository's database. Multiple [`GitCheckout`]s
161/// can be cloned from a single [`GitDatabase`].
162pub(crate) struct GitDatabase {
163    /// Underlying Git repository instance for this database.
164    repo: GitRepository,
165    /// Git LFS artifacts have been initialized (if requested).
166    lfs_ready: Option<bool>,
167}
168
169/// A local checkout of a particular revision from a [`GitRepository`].
170pub(crate) struct GitCheckout {
171    /// The git revision this checkout is for.
172    revision: GitOid,
173    /// Underlying Git repository instance for this checkout.
174    repo: GitRepository,
175    /// Git LFS artifacts have been initialized (if requested).
176    lfs_ready: Option<bool>,
177}
178
179/// A local Git repository.
180pub(crate) struct GitRepository {
181    /// Path to the underlying Git repository on the local filesystem.
182    path: PathBuf,
183}
184
185impl GitRepository {
186    /// Opens an existing Git repository at `path`.
187    pub(crate) fn open(path: &Path) -> Result<Self> {
188        // Make sure there is a Git repository at the specified path.
189        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    /// Initializes a Git repository at `path`.
201    fn init(path: &Path) -> Result<Self> {
202        // TODO(ibraheem): see if this still necessary now that we no longer use libgit2
203        // Skip anything related to templates, they just call all sorts of issues as
204        // we really don't want to use them yet they insist on being used. See #6240
205        // for an example issue that comes up.
206        // opts.external_template(false);
207
208        // Initialize the repository.
209        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    /// Parses the object ID of the given `refname`.
221    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    /// Verifies LFS artifacts have been initialized for a given `refname`.
236    #[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        // Requires Git LFS 3.x (2021 release)
246        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    /// Creates an instance for a remote repository URL.
275    pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
276        Self { url: url.clone() }
277    }
278
279    /// Gets the remote repository URL.
280    pub(crate) fn url(&self) -> &DisplaySafeUrl {
281        &self.url
282    }
283
284    /// Fetches and checkouts to a reference or a revision from this remote
285    /// into a local path.
286    ///
287    /// This ensures that it gets the up-to-date commit when a named reference
288    /// is given (tag, branch, refs/*). Thus, network connection is involved.
289    ///
290    /// When `locked_rev` is provided, it takes precedence over `reference`.
291    ///
292    /// If we have a previous instance of [`GitDatabase`] then fetch into that
293    /// if we can. If that can successfully load our revision then we've
294    /// populated the database with the latest version of `reference`, so
295    /// return that database and the rev we resolve to.
296    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        // Otherwise start from scratch to handle corrupt git repositories.
329        // After our fetch (which is interpreted as a clone now) we do the same
330        // resolution to figure out what we cloned.
331        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    /// Creates a [`GitDatabase`] of this remote at `db_path`.
356    #[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    /// Checkouts to a revision at `destination` from this database.
368    pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
369        // If the existing checkout exists, and it is fresh, use it.
370        // A non-fresh checkout can happen if the checkout operation was
371        // interrupted. In that case, the checkout gets deleted and a new
372        // clone is created.
373        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    /// Get a short OID for a `revision`, usually 7 chars or more if ambiguous.
385    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    /// Checks if `oid` resolves to a commit in this database.
401    pub(crate) fn contains(&self, oid: GitOid) -> bool {
402        self.repo.rev_parse(&format!("{oid}^0")).is_ok()
403    }
404
405    /// Checks if `oid` contains necessary LFS artifacts in this database.
406    pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
407        self.repo.lfs_fsck_objects(&format!("{oid}^0"))
408    }
409
410    /// Set the Git LFS validation state (if any).
411    #[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    /// Creates an instance of [`GitCheckout`]. This doesn't imply the checkout
420    /// is done. Use [`GitCheckout::is_fresh`] to check.
421    ///
422    /// * The `repo` will be the checked out Git repository.
423    fn new(revision: GitOid, repo: GitRepository) -> Self {
424        Self {
425            revision,
426            repo,
427            lfs_ready: None,
428        }
429    }
430
431    /// Clone a repo for a `revision` into a local path from a `database`.
432    /// This is a filesystem-to-filesystem clone.
433    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        // Perform a local clone of the repository, which will attempt to use
443        // hardlinks to set up the repository. This should speed up the clone operation
444        // quite a bit if it works.
445        let res = GIT
446            .as_ref()
447            .cloned()?
448            .arg("clone")
449            .arg("--local")
450            // Make sure to pass the local file path and not a file://... url. If given a url,
451            // Git treats the repository as a remote origin and gets confused because we don't
452            // have a HEAD checked out.
453            .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    /// Checks if the `HEAD` of this checkout points to the expected revision.
476    fn is_fresh(&self) -> bool {
477        match self.repo.rev_parse("HEAD") {
478            Ok(id) if id == self.revision => {
479                // See comments in reset() for why we check this
480                self.repo.path.join(CHECKOUT_READY_LOCK).exists()
481            }
482            _ => false,
483        }
484    }
485
486    /// Indicates Git LFS artifacts have been initialized (when requested).
487    pub(crate) fn lfs_ready(&self) -> Option<bool> {
488        self.lfs_ready
489    }
490
491    /// Set the Git LFS validation state (if any).
492    #[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    /// This performs `git reset --hard` to the revision of this checkout, with
499    /// additional interrupt protection by a dummy file [`CHECKOUT_READY_LOCK`].
500    ///
501    /// If we're interrupted while performing a `git reset` (e.g., we die
502    /// because of a signal) uv needs to be sure to try to check out this
503    /// repo again on the next go-round.
504    ///
505    /// To enable this we have a dummy file in our checkout, [`.ok`],
506    /// which if present means that the repo has been successfully reset and is
507    /// ready to go. Hence, if we start to do a reset, we make sure this file
508    /// *doesn't* exist, and then once we're done we create the file.
509    ///
510    /// [`.ok`]: CHECKOUT_READY_LOCK
511    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        // We want to skip smudge if lfs was disabled for the repository
516        // as smudge filters can trigger on a reset even if lfs artifacts
517        // were not originally "fetched".
518        let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
519        debug!("Reset {} to {}", self.repo.path.display(), self.revision);
520
521        // Perform the hard reset.
522        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        // Update submodules (`git submodule update --recursive`).
532        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        // Validate Git LFS objects (if needed) after the reset.
544        // See `fetch_lfs` why we do this.
545        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        // The .ok file should be written when the reset is successful.
552        // When Git LFS is enabled, the objects must also be fetched and
553        // validated successfully as part of the corresponding db.
554        if with_lfs.is_none() || lfs_validation == Some(true) {
555            paths::create(ok_file)?;
556        }
557
558        Ok(lfs_validation)
559    }
560}
561
562/// Attempts to fetch the given git `reference` for a Git repository.
563///
564/// This is the main entry for git clone/fetch. It does the following:
565///
566/// * Turns [`GitReference`] into refspecs accordingly.
567/// * Dispatches `git fetch` using the git CLI.
568///
569/// The `remote_url` argument is the git remote URL where we want to fetch from.
570fn 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        // If we know the reference is a full commit hash, we can just return it without
586        // querying GitHub.
587        Some(rev)
588    } else {
589        None
590    };
591
592    // Translate the reference desired here into an actual list of refspecs
593    // which need to get fetched. Additionally record if we're fetching tags.
594    let mut refspecs = Vec::new();
595    let mut tags = false;
596    let mut refspec_strategy = RefspecStrategy::All;
597    // The `+` symbol on the refspec means to allow a forced (fast-forward)
598    // update which is needed if there is ever a force push that requires a
599    // fast-forward.
600    match reference {
601        // For branches and tags we can fetch simply one reference and copy it
602        // locally, no need to fetch other branches/tags.
603        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        // For ambiguous references, we can fetch the exact commit (if known); otherwise,
622        // we fetch all branches and tags.
623        ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
624            // The `oid_to_fetch` is the exact commit we want to fetch. But it could be the exact
625            // commit of a branch or tag. We should only fetch it directly if it's the exact commit
626            // of a short commit hash.
627            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                // We don't know what the rev will point to. To handle this
633                // situation we fetch all branches and tags, and then we pray
634                // it's somewhere in there.
635                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            // Try each refspec
666            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                    // Stop after the first success and log failures
679                    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                    // Use the last error for the message
692                    result
693                } else {
694                    // Can only occur if there were no refspecs to fetch
695                    Ok(())
696                }
697            } else {
698                Ok(())
699            }
700        }
701    };
702    match reference {
703        // With the default branch, adding context is confusing
704        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
715/// Attempts to use `git` CLI installed on the system to fetch a repository.
716fn 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    // Disable interactive prompts in the terminal, as they'll be erased by the progress bar
726    // animation and the process will "hang". Interactive prompts via the GUI like `SSH_ASKPASS`
727    // are still usable.
728    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") // handle force pushes
743        .arg("--update-head-ok") // see discussion in #2078
744        .arg(url.as_str())
745        .args(refspecs)
746        .cwd(&repo.path);
747
748    // We capture the output to avoid streaming it to the user's console during clones.
749    // The required `on...line` callbacks currently do nothing.
750    // The output appears to be included in error messages by default.
751    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
762/// A global cache of the `git lfs` command.
763///
764/// Returns an error if Git LFS isn't available.
765/// Caching the command allows us to only check if LFS is installed once.
766///
767/// We also support a helper private environment variable to allow
768/// controlling the LFS extension from being loaded for testing purposes.
769/// Once installed, Git will always load `git-lfs` as a built-in alias
770/// which takes priority over loading from `PATH` which prevents us
771/// from shadowing the extension with other means.
772pub 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    // Run a simple command to verify LFS is installed
781    cmd.clone().arg("version").exec_with_output()?;
782    Ok(cmd)
783});
784
785/// Attempts to use `git-lfs` CLI to fetch required LFS objects for a given revision.
786fn 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        // Since this feature is opt-in, warn if not available
797        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        // We should not support requesting LFS artifacts with skip smudge being set.
810        // While this may not be necessary, it's added to avoid any potential future issues.
811        .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
812        .cwd(&repo.path);
813
814    cmd.exec_with_output()?;
815
816    // We now validate the Git LFS objects explicitly (if supported). This is
817    // needed to avoid issues with Git LFS not being installed or configured
818    // on the system and giving the wrong impression to the user that Git LFS
819    // objects were initialized correctly when installation finishes.
820    // We may want to allow the user to skip validation in the future via
821    // UV_GIT_LFS_NO_VALIDATION environment variable on rare cases where
822    // validation costs outweigh the benefit.
823    let validation_result = repo.lfs_fsck_objects(revision.as_str());
824
825    Ok(validation_result)
826}
827
828/// Whether `rev` is a shorter hash of `oid`.
829fn 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}