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};
10use cargo_util::{ProcessBuilder, paths};
11use tracing::{debug, warn};
12use url::Url;
13
14use uv_fs::Simplified;
15use uv_git_types::{GitOid, GitReference};
16use uv_redacted::DisplaySafeUrl;
17use uv_static::EnvVars;
18
19/// A file indicates that if present, `git reset` has been done and a repo
20/// checkout is ready to go. See [`GitCheckout::reset`] for why we need this.
21const CHECKOUT_READY_LOCK: &str = ".ok";
22
23#[derive(Debug, thiserror::Error)]
24pub enum GitError {
25    #[error("Git executable not found. Ensure that Git is installed and available.")]
26    GitNotFound,
27    #[error(transparent)]
28    Other(#[from] which::Error),
29    #[error(
30        "Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
31    )]
32    TransportNotAllowed,
33}
34
35/// A global cache of the result of `which git`.
36pub static GIT: LazyLock<Result<PathBuf, GitError>> = LazyLock::new(|| {
37    which::which("git").map_err(|err| match err {
38        which::Error::CannotFindBinaryPath => GitError::GitNotFound,
39        err => GitError::Other(err),
40    })
41});
42
43/// Strategy when fetching refspecs for a [`GitReference`]
44enum RefspecStrategy {
45    /// All refspecs should be fetched, if any fail then the fetch will fail.
46    All,
47    /// Stop after the first successful fetch, if none succeed then the fetch will fail.
48    First,
49}
50
51/// A Git reference (like a tag or branch) or a specific commit.
52#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
53enum ReferenceOrOid<'reference> {
54    /// A Git reference, like a tag or branch.
55    Reference(&'reference GitReference),
56    /// A specific commit.
57    Oid(GitOid),
58}
59
60impl ReferenceOrOid<'_> {
61    /// Resolves the [`ReferenceOrOid`] to an object ID with objects the `repo` currently has.
62    fn resolve(&self, repo: &GitRepository) -> Result<GitOid> {
63        let refkind = self.kind_str();
64        let result = match self {
65            // Resolve the commit pointed to by the tag.
66            //
67            // `^0` recursively peels away from the revision to the underlying commit object.
68            // This also verifies that the tag indeed refers to a commit.
69            Self::Reference(GitReference::Tag(s)) => {
70                repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))
71            }
72
73            // Resolve the commit pointed to by the branch.
74            Self::Reference(GitReference::Branch(s)) => repo.rev_parse(&format!("origin/{s}^0")),
75
76            // Attempt to resolve the branch, then the tag.
77            Self::Reference(GitReference::BranchOrTag(s)) => repo
78                .rev_parse(&format!("origin/{s}^0"))
79                .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0"))),
80
81            // Attempt to resolve the branch, then the tag, then the commit.
82            Self::Reference(GitReference::BranchOrTagOrCommit(s)) => repo
83                .rev_parse(&format!("origin/{s}^0"))
84                .or_else(|_| repo.rev_parse(&format!("refs/remotes/origin/tags/{s}^0")))
85                .or_else(|_| repo.rev_parse(&format!("{s}^0"))),
86
87            // We'll be using the HEAD commit.
88            Self::Reference(GitReference::DefaultBranch) => {
89                repo.rev_parse("refs/remotes/origin/HEAD")
90            }
91
92            // Resolve a named reference.
93            Self::Reference(GitReference::NamedRef(s)) => repo.rev_parse(&format!("{s}^0")),
94
95            // Resolve a specific commit.
96            Self::Oid(s) => repo.rev_parse(&format!("{s}^0")),
97        };
98
99        result.with_context(|| anyhow::format_err!("failed to find {refkind} `{self}`"))
100    }
101
102    /// Returns the kind of this [`ReferenceOrOid`].
103    fn kind_str(&self) -> &str {
104        match self {
105            Self::Reference(reference) => reference.kind_str(),
106            Self::Oid(_) => "commit",
107        }
108    }
109
110    /// Converts the [`ReferenceOrOid`] to a `str` that can be used as a revision.
111    fn as_rev(&self) -> &str {
112        match self {
113            Self::Reference(r) => r.as_rev(),
114            Self::Oid(rev) => rev.as_str(),
115        }
116    }
117}
118
119impl Display for ReferenceOrOid<'_> {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::Reference(reference) => write!(f, "{reference}"),
123            Self::Oid(oid) => write!(f, "{oid}"),
124        }
125    }
126}
127
128/// A remote repository. It gets cloned into a local [`GitDatabase`].
129#[derive(PartialEq, Clone, Debug)]
130pub(crate) struct GitRemote {
131    /// URL to a remote repository.
132    url: DisplaySafeUrl,
133}
134
135/// A local clone of a remote repository's database. Multiple [`GitCheckout`]s
136/// can be cloned from a single [`GitDatabase`].
137pub(crate) struct GitDatabase {
138    /// Underlying Git repository instance for this database.
139    repo: GitRepository,
140}
141
142/// A local checkout of a particular revision from a [`GitRepository`].
143pub(crate) struct GitCheckout {
144    /// The git revision this checkout is for.
145    revision: GitOid,
146    /// Underlying Git repository instance for this checkout.
147    repo: GitRepository,
148}
149
150/// A local Git repository.
151pub(crate) struct GitRepository {
152    /// Path to the underlying Git repository on the local filesystem.
153    path: PathBuf,
154}
155
156impl GitRepository {
157    /// Opens an existing Git repository at `path`.
158    pub(crate) fn open(path: &Path) -> Result<Self> {
159        // Make sure there is a Git repository at the specified path.
160        ProcessBuilder::new(GIT.as_ref()?)
161            .arg("rev-parse")
162            .cwd(path)
163            .exec_with_output()?;
164
165        Ok(Self {
166            path: path.to_path_buf(),
167        })
168    }
169
170    /// Initializes a Git repository at `path`.
171    fn init(path: &Path) -> Result<Self> {
172        // TODO(ibraheem): see if this still necessary now that we no longer use libgit2
173        // Skip anything related to templates, they just call all sorts of issues as
174        // we really don't want to use them yet they insist on being used. See #6240
175        // for an example issue that comes up.
176        // opts.external_template(false);
177
178        // Initialize the repository.
179        ProcessBuilder::new(GIT.as_ref()?)
180            .arg("init")
181            .cwd(path)
182            .exec_with_output()?;
183
184        Ok(Self {
185            path: path.to_path_buf(),
186        })
187    }
188
189    /// Parses the object ID of the given `refname`.
190    fn rev_parse(&self, refname: &str) -> Result<GitOid> {
191        let result = ProcessBuilder::new(GIT.as_ref()?)
192            .arg("rev-parse")
193            .arg(refname)
194            .cwd(&self.path)
195            .exec_with_output()?;
196
197        let mut result = String::from_utf8(result.stdout)?;
198        result.truncate(result.trim_end().len());
199        Ok(result.parse()?)
200    }
201}
202
203impl GitRemote {
204    /// Creates an instance for a remote repository URL.
205    pub(crate) fn new(url: &DisplaySafeUrl) -> Self {
206        Self { url: url.clone() }
207    }
208
209    /// Gets the remote repository URL.
210    pub(crate) fn url(&self) -> &DisplaySafeUrl {
211        &self.url
212    }
213
214    /// Fetches and checkouts to a reference or a revision from this remote
215    /// into a local path.
216    ///
217    /// This ensures that it gets the up-to-date commit when a named reference
218    /// is given (tag, branch, refs/*). Thus, network connection is involved.
219    ///
220    /// When `locked_rev` is provided, it takes precedence over `reference`.
221    ///
222    /// If we have a previous instance of [`GitDatabase`] then fetch into that
223    /// if we can. If that can successfully load our revision then we've
224    /// populated the database with the latest version of `reference`, so
225    /// return that database and the rev we resolve to.
226    pub(crate) fn checkout(
227        &self,
228        into: &Path,
229        db: Option<GitDatabase>,
230        reference: &GitReference,
231        locked_rev: Option<GitOid>,
232        disable_ssl: bool,
233        offline: bool,
234    ) -> Result<(GitDatabase, GitOid)> {
235        let reference = locked_rev
236            .map(ReferenceOrOid::Oid)
237            .unwrap_or(ReferenceOrOid::Reference(reference));
238        let enable_lfs_fetch = std::env::var(EnvVars::UV_GIT_LFS).is_ok();
239
240        if let Some(mut db) = db {
241            fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
242                .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
243
244            let resolved_commit_hash = match locked_rev {
245                Some(rev) => db.contains(rev).then_some(rev),
246                None => reference.resolve(&db.repo).ok(),
247            };
248
249            if let Some(rev) = resolved_commit_hash {
250                if enable_lfs_fetch {
251                    fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
252                        .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
253                }
254                return Ok((db, rev));
255            }
256        }
257
258        // Otherwise start from scratch to handle corrupt git repositories.
259        // After our fetch (which is interpreted as a clone now) we do the same
260        // resolution to figure out what we cloned.
261        match fs_err::remove_dir_all(into) {
262            Ok(()) => {}
263            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
264            Err(e) => return Err(e.into()),
265        }
266
267        fs_err::create_dir_all(into)?;
268        let mut repo = GitRepository::init(into)?;
269        fetch(&mut repo, &self.url, reference, disable_ssl, offline)
270            .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
271        let rev = match locked_rev {
272            Some(rev) => rev,
273            None => reference.resolve(&repo)?,
274        };
275        if enable_lfs_fetch {
276            fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
277                .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
278        }
279
280        Ok((GitDatabase { repo }, rev))
281    }
282
283    /// Creates a [`GitDatabase`] of this remote at `db_path`.
284    #[allow(clippy::unused_self)]
285    pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
286        let repo = GitRepository::open(db_path)?;
287        Ok(GitDatabase { repo })
288    }
289}
290
291impl GitDatabase {
292    /// Checkouts to a revision at `destination` from this database.
293    pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
294        // If the existing checkout exists, and it is fresh, use it.
295        // A non-fresh checkout can happen if the checkout operation was
296        // interrupted. In that case, the checkout gets deleted and a new
297        // clone is created.
298        let checkout = match GitRepository::open(destination)
299            .ok()
300            .map(|repo| GitCheckout::new(rev, repo))
301            .filter(GitCheckout::is_fresh)
302        {
303            Some(co) => co,
304            None => GitCheckout::clone_into(destination, self, rev)?,
305        };
306        Ok(checkout)
307    }
308
309    /// Get a short OID for a `revision`, usually 7 chars or more if ambiguous.
310    pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
311        let output = ProcessBuilder::new(GIT.as_ref()?)
312            .arg("rev-parse")
313            .arg("--short")
314            .arg(revision.as_str())
315            .cwd(&self.repo.path)
316            .exec_with_output()?;
317
318        let mut result = String::from_utf8(output.stdout)?;
319        result.truncate(result.trim_end().len());
320        Ok(result)
321    }
322
323    /// Checks if `oid` resolves to a commit in this database.
324    pub(crate) fn contains(&self, oid: GitOid) -> bool {
325        self.repo.rev_parse(&format!("{oid}^0")).is_ok()
326    }
327}
328
329impl GitCheckout {
330    /// Creates an instance of [`GitCheckout`]. This doesn't imply the checkout
331    /// is done. Use [`GitCheckout::is_fresh`] to check.
332    ///
333    /// * The `repo` will be the checked out Git repository.
334    fn new(revision: GitOid, repo: GitRepository) -> Self {
335        Self { revision, repo }
336    }
337
338    /// Clone a repo for a `revision` into a local path from a `database`.
339    /// This is a filesystem-to-filesystem clone.
340    fn clone_into(into: &Path, database: &GitDatabase, revision: GitOid) -> Result<Self> {
341        let dirname = into.parent().unwrap();
342        fs_err::create_dir_all(dirname)?;
343        match fs_err::remove_dir_all(into) {
344            Ok(()) => {}
345            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
346            Err(e) => return Err(e.into()),
347        }
348
349        // Perform a local clone of the repository, which will attempt to use
350        // hardlinks to set up the repository. This should speed up the clone operation
351        // quite a bit if it works.
352        let res = ProcessBuilder::new(GIT.as_ref()?)
353            .arg("clone")
354            .arg("--local")
355            // Make sure to pass the local file path and not a file://... url. If given a url,
356            // Git treats the repository as a remote origin and gets confused because we don't
357            // have a HEAD checked out.
358            .arg(database.repo.path.simplified_display().to_string())
359            .arg(into.simplified_display().to_string())
360            .exec_with_output();
361
362        if let Err(e) = res {
363            debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
364
365            ProcessBuilder::new(GIT.as_ref()?)
366                .arg("clone")
367                .arg("--no-hardlinks")
368                .arg(database.repo.path.simplified_display().to_string())
369                .arg(into.simplified_display().to_string())
370                .exec_with_output()?;
371        }
372
373        let repo = GitRepository::open(into)?;
374        let checkout = Self::new(revision, repo);
375        checkout.reset()?;
376        Ok(checkout)
377    }
378
379    /// Checks if the `HEAD` of this checkout points to the expected revision.
380    fn is_fresh(&self) -> bool {
381        match self.repo.rev_parse("HEAD") {
382            Ok(id) if id == self.revision => {
383                // See comments in reset() for why we check this
384                self.repo.path.join(CHECKOUT_READY_LOCK).exists()
385            }
386            _ => false,
387        }
388    }
389
390    /// This performs `git reset --hard` to the revision of this checkout, with
391    /// additional interrupt protection by a dummy file [`CHECKOUT_READY_LOCK`].
392    ///
393    /// If we're interrupted while performing a `git reset` (e.g., we die
394    /// because of a signal) Cargo needs to be sure to try to check out this
395    /// repo again on the next go-round.
396    ///
397    /// To enable this we have a dummy file in our checkout, [`.cargo-ok`],
398    /// which if present means that the repo has been successfully reset and is
399    /// ready to go. Hence if we start to do a reset, we make sure this file
400    /// *doesn't* exist, and then once we're done we create the file.
401    ///
402    /// [`.cargo-ok`]: CHECKOUT_READY_LOCK
403    fn reset(&self) -> Result<()> {
404        let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
405        let _ = paths::remove_file(&ok_file);
406        debug!("Reset {} to {}", self.repo.path.display(), self.revision);
407
408        // Perform the hard reset.
409        ProcessBuilder::new(GIT.as_ref()?)
410            .arg("reset")
411            .arg("--hard")
412            .arg(self.revision.as_str())
413            .cwd(&self.repo.path)
414            .exec_with_output()?;
415
416        // Update submodules (`git submodule update --recursive`).
417        ProcessBuilder::new(GIT.as_ref()?)
418            .arg("submodule")
419            .arg("update")
420            .arg("--recursive")
421            .arg("--init")
422            .cwd(&self.repo.path)
423            .exec_with_output()
424            .map(drop)?;
425
426        paths::create(ok_file)?;
427        Ok(())
428    }
429}
430
431/// Attempts to fetch the given git `reference` for a Git repository.
432///
433/// This is the main entry for git clone/fetch. It does the following:
434///
435/// * Turns [`GitReference`] into refspecs accordingly.
436/// * Dispatches `git fetch` using the git CLI.
437///
438/// The `remote_url` argument is the git remote URL where we want to fetch from.
439fn fetch(
440    repo: &mut GitRepository,
441    remote_url: &Url,
442    reference: ReferenceOrOid<'_>,
443    disable_ssl: bool,
444    offline: bool,
445) -> Result<()> {
446    let oid_to_fetch = if let ReferenceOrOid::Oid(rev) = reference {
447        let local_object = reference.resolve(repo).ok();
448        if let Some(local_object) = local_object {
449            if rev == local_object {
450                return Ok(());
451            }
452        }
453
454        // If we know the reference is a full commit hash, we can just return it without
455        // querying GitHub.
456        Some(rev)
457    } else {
458        None
459    };
460
461    // Translate the reference desired here into an actual list of refspecs
462    // which need to get fetched. Additionally record if we're fetching tags.
463    let mut refspecs = Vec::new();
464    let mut tags = false;
465    let mut refspec_strategy = RefspecStrategy::All;
466    // The `+` symbol on the refspec means to allow a forced (fast-forward)
467    // update which is needed if there is ever a force push that requires a
468    // fast-forward.
469    match reference {
470        // For branches and tags we can fetch simply one reference and copy it
471        // locally, no need to fetch other branches/tags.
472        ReferenceOrOid::Reference(GitReference::Branch(branch)) => {
473            refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
474        }
475
476        ReferenceOrOid::Reference(GitReference::Tag(tag)) => {
477            refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
478        }
479
480        ReferenceOrOid::Reference(GitReference::BranchOrTag(branch_or_tag)) => {
481            refspecs.push(format!(
482                "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
483            ));
484            refspecs.push(format!(
485                "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
486            ));
487            refspec_strategy = RefspecStrategy::First;
488        }
489
490        // For ambiguous references, we can fetch the exact commit (if known); otherwise,
491        // we fetch all branches and tags.
492        ReferenceOrOid::Reference(GitReference::BranchOrTagOrCommit(branch_or_tag_or_commit)) => {
493            // The `oid_to_fetch` is the exact commit we want to fetch. But it could be the exact
494            // commit of a branch or tag. We should only fetch it directly if it's the exact commit
495            // of a short commit hash.
496            if let Some(oid_to_fetch) =
497                oid_to_fetch.filter(|oid| is_short_hash_of(branch_or_tag_or_commit, *oid))
498            {
499                refspecs.push(format!("+{oid_to_fetch}:refs/commit/{oid_to_fetch}"));
500            } else {
501                // We don't know what the rev will point to. To handle this
502                // situation we fetch all branches and tags, and then we pray
503                // it's somewhere in there.
504                refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
505                refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
506                tags = true;
507            }
508        }
509
510        ReferenceOrOid::Reference(GitReference::DefaultBranch) => {
511            refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
512        }
513
514        ReferenceOrOid::Reference(GitReference::NamedRef(rev)) => {
515            refspecs.push(format!("+{rev}:{rev}"));
516        }
517
518        ReferenceOrOid::Oid(rev) => {
519            refspecs.push(format!("+{rev}:refs/commit/{rev}"));
520        }
521    }
522
523    debug!("Performing a Git fetch for: {remote_url}");
524    let result = match refspec_strategy {
525        RefspecStrategy::All => fetch_with_cli(
526            repo,
527            remote_url,
528            refspecs.as_slice(),
529            tags,
530            disable_ssl,
531            offline,
532        ),
533        RefspecStrategy::First => {
534            // Try each refspec
535            let mut errors = refspecs
536                .iter()
537                .map_while(|refspec| {
538                    let fetch_result = fetch_with_cli(
539                        repo,
540                        remote_url,
541                        std::slice::from_ref(refspec),
542                        tags,
543                        disable_ssl,
544                        offline,
545                    );
546
547                    // Stop after the first success and log failures
548                    match fetch_result {
549                        Err(ref err) => {
550                            debug!("Failed to fetch refspec `{refspec}`: {err}");
551                            Some(fetch_result)
552                        }
553                        Ok(()) => None,
554                    }
555                })
556                .collect::<Vec<_>>();
557
558            if errors.len() == refspecs.len() {
559                if let Some(result) = errors.pop() {
560                    // Use the last error for the message
561                    result
562                } else {
563                    // Can only occur if there were no refspecs to fetch
564                    Ok(())
565                }
566            } else {
567                Ok(())
568            }
569        }
570    };
571    match reference {
572        // With the default branch, adding context is confusing
573        ReferenceOrOid::Reference(GitReference::DefaultBranch) => result,
574        _ => result.with_context(|| {
575            format!(
576                "failed to fetch {} `{}`",
577                reference.kind_str(),
578                reference.as_rev()
579            )
580        }),
581    }
582}
583
584/// Attempts to use `git` CLI installed on the system to fetch a repository.
585fn fetch_with_cli(
586    repo: &mut GitRepository,
587    url: &Url,
588    refspecs: &[String],
589    tags: bool,
590    disable_ssl: bool,
591    offline: bool,
592) -> Result<()> {
593    let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
594    // Disable interactive prompts in the terminal, as they'll be erased by the progress bar
595    // animation and the process will "hang". Interactive prompts via the GUI like `SSH_ASKPASS`
596    // are still usable.
597    cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
598
599    cmd.arg("fetch");
600    if tags {
601        cmd.arg("--tags");
602    }
603    if disable_ssl {
604        debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
605        cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
606    }
607    if offline {
608        debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
609        cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
610    }
611    cmd.arg("--force") // handle force pushes
612        .arg("--update-head-ok") // see discussion in #2078
613        .arg(url.as_str())
614        .args(refspecs)
615        // If cargo is run by git (for example, the `exec` command in `git
616        // rebase`), the GIT_DIR is set by git and will point to the wrong
617        // location (this takes precedence over the cwd). Make sure this is
618        // unset so git will look at cwd for the repo.
619        .env_remove(EnvVars::GIT_DIR)
620        // The reset of these may not be necessary, but I'm including them
621        // just to be extra paranoid and avoid any issues.
622        .env_remove(EnvVars::GIT_WORK_TREE)
623        .env_remove(EnvVars::GIT_INDEX_FILE)
624        .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
625        .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
626        .cwd(&repo.path);
627
628    // We capture the output to avoid streaming it to the user's console during clones.
629    // The required `on...line` callbacks currently do nothing.
630    // The output appears to be included in error messages by default.
631    cmd.exec_with_output().map_err(|err| {
632        let msg = err.to_string();
633        if msg.contains("transport '") && msg.contains("' not allowed") && offline {
634            return GitError::TransportNotAllowed.into();
635        }
636        err
637    })?;
638
639    Ok(())
640}
641
642/// A global cache of the `git lfs` command.
643///
644/// Returns an error if Git LFS isn't available.
645/// Caching the command allows us to only check if LFS is installed once.
646static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
647    let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
648    cmd.arg("lfs");
649
650    // Run a simple command to verify LFS is installed
651    cmd.clone().arg("version").exec_with_output()?;
652    Ok(cmd)
653});
654
655/// Attempts to use `git-lfs` CLI to fetch required LFS objects for a given revision.
656fn fetch_lfs(
657    repo: &mut GitRepository,
658    url: &Url,
659    revision: &GitOid,
660    disable_ssl: bool,
661) -> Result<()> {
662    let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
663        debug!("Fetching Git LFS objects");
664        lfs.clone()
665    } else {
666        // Since this feature is opt-in, warn if not available
667        warn!("Git LFS is not available, skipping LFS fetch");
668        return Ok(());
669    };
670
671    if disable_ssl {
672        debug!("Disabling SSL verification for Git LFS");
673        cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
674    }
675
676    cmd.arg("fetch")
677        .arg(url.as_str())
678        .arg(revision.as_str())
679        // These variables are unset for the same reason as in `fetch_with_cli`.
680        .env_remove(EnvVars::GIT_DIR)
681        .env_remove(EnvVars::GIT_WORK_TREE)
682        .env_remove(EnvVars::GIT_INDEX_FILE)
683        .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
684        .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
685        .cwd(&repo.path);
686
687    cmd.exec_with_output()?;
688    Ok(())
689}
690
691/// Whether `rev` is a shorter hash of `oid`.
692fn is_short_hash_of(rev: &str, oid: GitOid) -> bool {
693    let long_hash = oid.to_string();
694    match long_hash.get(..rev.len()) {
695        Some(truncated_long_hash) => truncated_long_hash.eq_ignore_ascii_case(rev),
696        None => false,
697    }
698}