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::path::{Path, PathBuf};
5use std::str::{self};
6use std::sync::LazyLock;
7
8use anyhow::{Context, Result, anyhow};
9use cargo_util::{ProcessBuilder, ProcessError, paths};
10use owo_colors::OwoColorize;
11use tracing::{debug, instrument, warn};
12
13use uv_fs::Simplified;
14use uv_git_types::{GitOid, GitReference};
15use uv_redacted::SafeUrl;
16use uv_static::EnvVars;
17use uv_warnings::warn_user_once;
18
19/// Extension for the marker beside a completed checkout.
20/// See [`GitCheckout::reset`] for why we need this.
21const CHECKOUT_READY_EXTENSION: &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("Git LFS extension not found. Ensure that Git LFS is installed and available.")]
28    GitLfsNotFound,
29    #[error("Is Git LFS configured? Run `{}` to initialize Git LFS.", "git lfs install".green())]
30    GitLfsNotConfigured,
31    #[error(transparent)]
32    Other(#[from] which::Error),
33    #[error(
34        "Remote Git fetches are not allowed because network connectivity is disabled (i.e., with `--offline`)"
35    )]
36    TransportNotAllowed,
37}
38
39/// A global cache of the result of `which git` as a command
40///
41/// Caching the command allows us to avoid needing to remove environment
42/// variables everywhere.
43pub static GIT: LazyLock<Result<ProcessBuilder, GitError>> = LazyLock::new(|| {
44    let path = which::which("git").map_err(|err| match err {
45        which::Error::CannotFindBinaryPath => GitError::GitNotFound,
46        err => GitError::Other(err),
47    })?;
48
49    let mut cmd = ProcessBuilder::new(path);
50
51    // Certain git environment variables never make sense to inherit because
52    // they affect what the current command will act on.
53
54    // This can cause problems if for example uv is ran by git (for example, the
55    // `exec` command in `git rebase`), the GIT_DIR is set by git and will point
56    // to the wrong location (this takes precedence over the cwd).
57    cmd.env_remove(EnvVars::GIT_DIR)
58        .env_remove(EnvVars::GIT_WORK_TREE)
59        .env_remove(EnvVars::GIT_INDEX_FILE)
60        .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
61        .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
62        .env_remove(EnvVars::GIT_COMMON_DIR);
63
64    Ok(cmd)
65});
66
67/// Strategy when fetching refspecs for a [`GitReference`]
68enum RefspecStrategy {
69    /// All refspecs should be fetched, if any fail then the fetch will fail.
70    All,
71    /// Stop after the first successful fetch, if none succeed then the fetch will fail.
72    First,
73}
74
75/// A remote repository. It gets cloned into a local [`GitDatabase`].
76#[derive(PartialEq, Clone, Debug)]
77pub(crate) struct GitRemote {
78    /// URL to a remote repository.
79    url: SafeUrl,
80}
81
82/// A local clone of a remote repository's database. Multiple [`GitCheckout`]s
83/// can be cloned from a single [`GitDatabase`].
84pub(crate) struct GitDatabase {
85    /// The remote repository where this database is fetched from.
86    remote: GitRemote,
87    /// Underlying Git repository instance for this database.
88    repo: GitRepository,
89    /// Git LFS artifacts have been initialized (if requested).
90    lfs_ready: Option<bool>,
91}
92
93/// A local checkout of a particular revision from a [`GitRepository`].
94pub(crate) struct GitCheckout {
95    /// The git revision this checkout is for.
96    revision: GitOid,
97    /// Underlying Git repository instance for this checkout.
98    repo: GitRepository,
99    /// Git LFS artifacts have been initialized (if requested).
100    lfs_ready: Option<bool>,
101}
102
103/// A local Git repository.
104pub(crate) struct GitRepository {
105    /// Path to the underlying Git repository on the local filesystem.
106    path: PathBuf,
107}
108
109impl GitRepository {
110    /// Opens an existing Git repository at `path`.
111    fn open(path: &Path) -> Result<Self> {
112        // Make sure there is a Git repository at the specified path.
113        GIT.as_ref()
114            .cloned()?
115            .arg("rev-parse")
116            .cwd(path)
117            .exec_with_output()?;
118
119        Ok(Self {
120            path: path.to_path_buf(),
121        })
122    }
123
124    /// Initializes a Git repository at `path`.
125    fn init(path: &Path) -> Result<Self> {
126        // TODO(ibraheem): see if this still necessary now that we no longer use libgit2
127        // Skip anything related to templates, they just call all sorts of issues as
128        // we really don't want to use them yet they insist on being used. See #6240
129        // for an example issue that comes up.
130        // opts.external_template(false);
131
132        // Initialize the repository.
133        GIT.as_ref()
134            .cloned()?
135            .arg("init")
136            .cwd(path)
137            .exec_with_output()?;
138
139        Ok(Self {
140            path: path.to_path_buf(),
141        })
142    }
143
144    /// Resolve a [`GitReference`] using the objects already in this repository.
145    fn resolve(&self, reference: &GitReference) -> Result<GitOid> {
146        let result = match reference {
147            // `^0` peels tags and verifies that the object refers to a commit.
148            GitReference::Tag(tag) => self.rev_parse(&format!("refs/remotes/origin/tags/{tag}^0")),
149            GitReference::Branch(branch) => self.rev_parse(&format!("origin/{branch}^0")),
150            GitReference::BranchOrTag(reference) => self
151                .rev_parse(&format!("origin/{reference}^0"))
152                .or_else(|_| self.rev_parse(&format!("refs/remotes/origin/tags/{reference}^0"))),
153            GitReference::BranchOrTagOrCommit(reference) => self
154                .rev_parse(&format!("origin/{reference}^0"))
155                .or_else(|_| self.rev_parse(&format!("refs/remotes/origin/tags/{reference}^0")))
156                .or_else(|_| self.rev_parse(&format!("{reference}^0"))),
157            GitReference::DefaultBranch => self.rev_parse("refs/remotes/origin/HEAD"),
158            GitReference::NamedRef(reference) => self.rev_parse(&format!("{reference}^0")),
159            GitReference::Commit(commit) => self.rev_parse(&format!("{commit}^0")),
160        };
161
162        result.with_context(|| format!("failed to find {} `{reference}`", reference.kind_str()))
163    }
164
165    /// Parses the object ID of the given `refname`.
166    fn rev_parse(&self, refname: &str) -> Result<GitOid> {
167        let result = GIT
168            .as_ref()
169            .cloned()?
170            .arg("rev-parse")
171            .arg(refname)
172            .cwd(&self.path)
173            .exec_with_output()?;
174
175        let mut result = String::from_utf8(result.stdout)?;
176        result.truncate(result.trim_end().len());
177        Ok(result.parse()?)
178    }
179
180    /// Verifies LFS artifacts have been initialized for a given `refname`.
181    #[instrument(skip_all, fields(path = %self.path.user_display(), refname = %refname))]
182    fn lfs_fsck_objects(&self, refname: &str) -> bool {
183        let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
184            lfs.clone()
185        } else {
186            warn!("Git LFS is not available, skipping LFS fetch");
187            return false;
188        };
189
190        // Requires Git LFS 3.x (2021 release)
191        let result = cmd
192            .arg("fsck")
193            .arg("--objects")
194            .arg(refname)
195            .cwd(&self.path)
196            .exec_with_output();
197
198        match result {
199            Ok(_) => true,
200            Err(err) => {
201                let lfs_error = err.to_string();
202                if lfs_error.contains("unknown flag: --objects") {
203                    warn_user_once!(
204                        "Skipping Git LFS validation as Git LFS extension is outdated. \
205                        Upgrade to `git-lfs>=3.0.2` or manually verify git-lfs objects were \
206                        properly fetched after the current operation finishes."
207                    );
208                    true
209                } else {
210                    debug!("Git LFS validation failed: {err}");
211                    false
212                }
213            }
214        }
215    }
216}
217
218impl GitRemote {
219    /// Creates an instance for a remote repository URL.
220    pub(crate) fn new(url: SafeUrl) -> Self {
221        Self { url }
222    }
223
224    /// Gets the remote repository URL.
225    pub(crate) fn url(&self) -> &SafeUrl {
226        &self.url
227    }
228
229    /// Fetches and checkouts to a reference or a revision from this remote
230    /// into a local path.
231    ///
232    /// This ensures that it gets the up-to-date commit when a named reference
233    /// is given (tag, branch, refs/*). Thus, network connection is involved.
234    ///
235    /// When `locked_rev` is provided, it takes precedence over `reference`.
236    ///
237    /// If we have a previous instance of [`GitDatabase`] then fetch into that
238    /// if we can. If that can successfully load our revision then we've
239    /// populated the database with the latest version of `reference`, so
240    /// return that database and the rev we resolve to.
241    pub(crate) fn checkout(
242        self,
243        into: &Path,
244        db: Option<GitDatabase>,
245        reference: &GitReference,
246        locked_rev: Option<GitOid>,
247        disable_ssl: bool,
248        offline: bool,
249        with_lfs: bool,
250    ) -> Result<(GitDatabase, GitOid)> {
251        let locked_reference = locked_rev.map(GitReference::Commit);
252        let reference = locked_reference.as_ref().unwrap_or(reference);
253        if let Some(mut db) = db {
254            fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
255                .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
256
257            if let Ok(rev) = db.repo.resolve(reference) {
258                if with_lfs {
259                    let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
260                        .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
261                    db = db.with_lfs_ready(Some(lfs_ready));
262                }
263                return Ok((db, rev));
264            }
265        }
266
267        // Otherwise start from scratch to handle corrupt git repositories.
268        // After our fetch (which is interpreted as a clone now) we do the same
269        // resolution to figure out what we cloned.
270        match fs_err::remove_dir_all(into) {
271            Ok(()) => {}
272            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
273            Err(e) => return Err(e.into()),
274        }
275
276        fs_err::create_dir_all(into)?;
277        let mut repo = GitRepository::init(into)?;
278        fetch(&mut repo, &self.url, reference, disable_ssl, offline)
279            .with_context(|| format!("failed to clone into: {}", into.user_display()))?;
280        let rev = repo.resolve(reference)?;
281        let lfs_ready = with_lfs
282            .then(|| {
283                fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
284                    .with_context(|| format!("failed to fetch LFS objects at {rev}"))
285            })
286            .transpose()?;
287
288        Ok((
289            GitDatabase {
290                remote: self,
291                repo,
292                lfs_ready,
293            },
294            rev,
295        ))
296    }
297
298    /// Creates a [`GitDatabase`] of this remote at `db_path`.
299    pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
300        let repo = GitRepository::open(db_path)?;
301        Ok(GitDatabase {
302            remote: self.clone(),
303            repo,
304            lfs_ready: None,
305        })
306    }
307}
308
309impl GitDatabase {
310    /// Checkouts to a revision at `destination` from this database.
311    pub(crate) fn copy_to(&self, rev: GitOid, destination: &Path) -> Result<GitCheckout> {
312        // If the existing checkout exists, and it is fresh, use it.
313        // A non-fresh checkout can happen if the checkout operation was
314        // interrupted. In that case, the checkout gets deleted and a new
315        // clone is created.
316        let checkout = match GitRepository::open(destination)
317            .ok()
318            .map(|repo| GitCheckout::new(rev, repo))
319            .filter(GitCheckout::is_fresh)
320        {
321            Some(co) => co.with_lfs_ready(self.lfs_ready),
322            None => GitCheckout::clone_into(destination, self, rev, self.remote.url())?,
323        };
324        Ok(checkout)
325    }
326
327    /// Get a short OID for a `revision`, usually 7 chars or more if ambiguous.
328    pub(crate) fn to_short_id(&self, revision: GitOid) -> Result<String> {
329        let output = GIT
330            .as_ref()
331            .cloned()?
332            .arg("rev-parse")
333            .arg("--short")
334            .arg(revision.as_str())
335            .cwd(&self.repo.path)
336            .exec_with_output()?;
337
338        let mut result = String::from_utf8(output.stdout)?;
339        result.truncate(result.trim_end().len());
340        Ok(result)
341    }
342
343    /// Checks if `oid` identifies a commit object in this database.
344    pub(crate) fn contains(&self, oid: GitOid) -> bool {
345        self.repo
346            .rev_parse(&format!("{oid}^0"))
347            .is_ok_and(|commit| commit == oid)
348    }
349
350    /// Checks if `oid` contains necessary LFS artifacts in this database.
351    pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
352        self.repo.lfs_fsck_objects(&format!("{oid}^0"))
353    }
354
355    /// Set the Git LFS validation state (if any).
356    #[must_use]
357    pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
358        self.lfs_ready = lfs;
359        self
360    }
361}
362
363impl GitCheckout {
364    /// Creates an instance of [`GitCheckout`]. This doesn't imply the checkout
365    /// is done. Use [`GitCheckout::is_fresh`] to check.
366    ///
367    /// * The `repo` will be the checked out Git repository.
368    fn new(revision: GitOid, repo: GitRepository) -> Self {
369        Self {
370            revision,
371            repo,
372            lfs_ready: None,
373        }
374    }
375
376    /// Clone a repo for a `revision` into a local path from a `database`.
377    /// This is a filesystem-to-filesystem clone.
378    fn clone_into(
379        into: &Path,
380        database: &GitDatabase,
381        revision: GitOid,
382        original_remote_url: &SafeUrl,
383    ) -> Result<Self> {
384        // Invalidate readiness before replacing the checkout, including an interrupted clone.
385        match fs_err::remove_file(into.with_extension(CHECKOUT_READY_EXTENSION)) {
386            Ok(()) => {}
387            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
388            Err(err) => return Err(err.into()),
389        }
390
391        let dirname = into.parent().unwrap();
392        fs_err::create_dir_all(dirname)?;
393        match fs_err::remove_dir_all(into) {
394            Ok(()) => {}
395            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
396            Err(e) => return Err(e.into()),
397        }
398
399        // Perform a local clone of the repository, which will attempt to use
400        // hardlinks to set up the repository. This should speed up the clone operation
401        // quite a bit if it works.
402        let res = GIT
403            .as_ref()
404            .cloned()?
405            .arg("clone")
406            .arg("--local")
407            // Make sure to pass the local file path and not a file://... url. If given a url,
408            // Git treats the repository as a remote origin and gets confused because we don't
409            // have a HEAD checked out.
410            .arg(database.repo.path.simplified_display().to_string())
411            .arg(into.simplified_display().to_string())
412            .exec_with_output();
413
414        if let Err(e) = res {
415            debug!("Cloning git repo with --local failed, retrying without hardlinks: {e}");
416
417            GIT.as_ref()
418                .cloned()?
419                .arg("clone")
420                .arg("--no-hardlinks")
421                .arg(database.repo.path.simplified_display().to_string())
422                .arg(into.simplified_display().to_string())
423                .exec_with_output()?;
424        }
425
426        let repo = GitRepository::open(into)?;
427        let checkout = Self::new(revision, repo);
428        let lfs_ready = checkout.reset(database.lfs_ready, original_remote_url)?;
429        Ok(checkout.with_lfs_ready(lfs_ready))
430    }
431
432    /// Checks if the `HEAD` of this checkout points to the expected revision.
433    fn is_fresh(&self) -> bool {
434        match self.repo.rev_parse("HEAD") {
435            Ok(id) if id == self.revision => {
436                // See comments in reset() for why we check this
437                self.repo
438                    .path
439                    .with_extension(CHECKOUT_READY_EXTENSION)
440                    .exists()
441            }
442            _ => false,
443        }
444    }
445
446    /// Indicates Git LFS artifacts have been initialized (when requested).
447    pub(crate) fn lfs_ready(&self) -> Option<bool> {
448        self.lfs_ready
449    }
450
451    /// Set the Git LFS validation state (if any).
452    #[must_use]
453    fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
454        self.lfs_ready = lfs;
455        self
456    }
457
458    /// This performs `git reset --hard` to the revision of this checkout and updates submodules,
459    /// with additional interrupt protection by a marker file.
460    ///
461    /// If we're interrupted while performing any of the processes in this method (e.g., we die
462    /// because of a signal) uv needs to be sure to try to check out this
463    /// repo again on the next go-round.
464    ///
465    /// The marker sits beside the checkout with an [`.ok` extension], so tracked files cannot
466    /// collide with it. It is removed before cloning and created only after preparation succeeds.
467    /// For example, there may be the `checkouts/<repository-key>/0123456789abcdef/` contents
468    /// directory containing `checkouts/<repository-key>/0123456789abcdef/pyproject.toml`, and the
469    /// `checkouts/<repository-key>/0123456789abcdef.ok` marker file besides it.
470    ///
471    /// [`.ok` extension]: CHECKOUT_READY_EXTENSION
472    /// `git reset --hard [<commit>]` can break relative submodule URLs, so we update submodules
473    /// using the original remote URL.
474    fn reset(&self, with_lfs: Option<bool>, original_remote_url: &SafeUrl) -> Result<Option<bool>> {
475        // We want to skip smudge if lfs was disabled for the repository
476        // as smudge filters can trigger on a reset even if lfs artifacts
477        // were not originally "fetched".
478        let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
479
480        debug!("Reset `{}` to {}", self.repo.path.display(), self.revision);
481
482        // Perform the hard reset.
483        GIT.as_ref()
484            .cloned()?
485            .arg("reset")
486            .arg("--hard")
487            .arg(self.revision.as_str())
488            .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
489            .cwd(&self.repo.path)
490            .exec_with_output()?;
491
492        // Initialize direct submodules using the original remote URL so Git can resolve relative
493        // submodule URLs, but don't write it to `remote.origin.url`. Git persists resolved submodule
494        // URLs during initialization, so writing a credentialed parent remote can leak credentials
495        // into checkout configuration.
496        //
497        // Do not use `--recursive` here: command-local `remote.origin.url` config is inherited by
498        // Git commands run inside submodules, which would make nested relative URLs resolve against
499        // the top-level remote instead of their immediate parent submodule.
500        let mut submodule_update = GIT.as_ref().cloned()?;
501        for config in submodule_update_config(original_remote_url) {
502            submodule_update.arg("-c").arg(config);
503        }
504
505        submodule_update
506            .arg("submodule")
507            .arg("update")
508            .arg("--init")
509            .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
510            .cwd(&self.repo.path)
511            .exec_with_output()
512            .map_err(|err| redact_git_error(err, original_remote_url))
513            .map(drop)?;
514
515        // Recursively update nested submodules without overriding `remote.origin.url`, so each
516        // nested relative URL resolves against its immediate parent submodule. The transient
517        // credential rewrite is still safe to inherit because it only affects transport.
518        let mut submodule_update = GIT.as_ref().cloned()?;
519        for config in submodule_auth_config(original_remote_url) {
520            submodule_update.arg("-c").arg(config);
521        }
522
523        submodule_update
524            .arg("submodule")
525            .arg("update")
526            .arg("--recursive")
527            .arg("--init")
528            .env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
529            .cwd(&self.repo.path)
530            .exec_with_output()
531            .map_err(|err| redact_git_error(err, original_remote_url))
532            .map(drop)?;
533
534        // Validate Git LFS objects (if needed) after the reset.
535        // See `fetch_lfs` why we do this.
536        let lfs_validation = match with_lfs {
537            None => None,
538            Some(false) => Some(false),
539            Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
540        };
541
542        // The .ok file should be written when the reset is successful.
543        // When Git LFS is enabled, the objects must also be fetched and
544        // validated successfully as part of the corresponding db.
545        if with_lfs.is_none() || lfs_validation == Some(true) {
546            paths::create(self.repo.path.with_extension(CHECKOUT_READY_EXTENSION))?;
547        }
548
549        Ok(lfs_validation)
550    }
551}
552
553/// Return command-local Git configuration for initializing direct submodules in a checkout.
554///
555/// Relative submodule URLs are resolved from `remote.origin.url`, but writing the original remote
556/// URL into checkout configuration can persist credentials in the parent repository or submodule
557/// remotes. Instead, callers pass these values via `git -c`, using a credential-stripped origin URL
558/// for resolution and a transient `url.*.insteadOf` rewrite when credentials are needed for
559/// transport.
560fn submodule_update_config(original_remote_url: &SafeUrl) -> Vec<String> {
561    let remote_url = original_remote_url.without_credentials();
562    let mut config = vec![format!("remote.origin.url={}", remote_url.as_str())];
563
564    config.extend(submodule_auth_config(original_remote_url));
565    config
566}
567
568/// Return command-local Git authentication configuration for updating submodules.
569///
570/// Unlike `remote.origin.url`, these rewrites are safe to inherit during recursive submodule
571/// updates: they rewrite transport URLs for authentication, but do not change the base URL that Git
572/// uses to resolve nested relative submodule URLs.
573fn submodule_auth_config(original_remote_url: &SafeUrl) -> Vec<String> {
574    let remote_url = original_remote_url.without_credentials();
575    let mut config = Vec::new();
576
577    if remote_url.as_str() != original_remote_url.as_str() {
578        let safe_root = remote_url_root(remote_url.into_owned());
579        let credentialed_root = remote_url_root(original_remote_url.clone());
580
581        if safe_root.as_str() != credentialed_root.as_str() {
582            config.push(format!(
583                "url.{}.insteadOf={}",
584                credentialed_root.as_str(),
585                safe_root.as_str()
586            ));
587        }
588    }
589
590    config
591}
592
593/// Return the scheme, authority, and root path of a remote URL.
594///
595/// This is used as the rewrite prefix for `url.*.insteadOf`, so a credentialed parent URL can
596/// authenticate sibling submodule URLs without making the credentials part of any persisted
597/// submodule URL.
598fn remote_url_root(mut url: SafeUrl) -> SafeUrl {
599    url.set_path("/");
600    url.set_query(None);
601    url.set_fragment(None);
602    url
603}
604
605/// Attempts to fetch the given git `reference` for a Git repository.
606///
607/// This is the main entry for git clone/fetch. It does the following:
608///
609/// * Turns [`GitReference`] into refspecs accordingly.
610/// * Dispatches `git fetch` using the git CLI.
611///
612/// The `remote_url` argument is the git remote URL where we want to fetch from.
613fn fetch(
614    repo: &mut GitRepository,
615    remote_url: &SafeUrl,
616    reference: &GitReference,
617    disable_ssl: bool,
618    offline: bool,
619) -> Result<()> {
620    if let GitReference::Commit(commit) = reference
621        && repo
622            .resolve(reference)
623            .is_ok_and(|local_commit| *commit == local_commit)
624    {
625        return Ok(());
626    }
627
628    // Translate the reference desired here into an actual list of refspecs
629    // which need to get fetched. Additionally record if we're fetching tags.
630    let mut refspecs = Vec::new();
631    let mut tags = false;
632    let mut refspec_strategy = RefspecStrategy::All;
633    // The `+` symbol on the refspec means to allow a forced (fast-forward)
634    // update which is needed if there is ever a force push that requires a
635    // fast-forward.
636    match reference {
637        // For branches and tags we can fetch simply one reference and copy it
638        // locally, no need to fetch other branches/tags.
639        GitReference::Branch(branch) => {
640            refspecs.push(format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"));
641        }
642
643        GitReference::Tag(tag) => {
644            refspecs.push(format!("+refs/tags/{tag}:refs/remotes/origin/tags/{tag}"));
645        }
646
647        GitReference::BranchOrTag(branch_or_tag) => {
648            refspecs.push(format!(
649                "+refs/heads/{branch_or_tag}:refs/remotes/origin/{branch_or_tag}"
650            ));
651            refspecs.push(format!(
652                "+refs/tags/{branch_or_tag}:refs/remotes/origin/tags/{branch_or_tag}"
653            ));
654            refspec_strategy = RefspecStrategy::First;
655        }
656
657        // Fetch all branches and tags so ambiguous references can resolve to either a named
658        // reference or a short commit hash.
659        GitReference::BranchOrTagOrCommit(_) => {
660            refspecs.push(String::from("+refs/heads/*:refs/remotes/origin/*"));
661            refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
662            tags = true;
663        }
664
665        GitReference::DefaultBranch => {
666            refspecs.push(String::from("+HEAD:refs/remotes/origin/HEAD"));
667        }
668
669        GitReference::NamedRef(rev) => {
670            refspecs.push(format!("+{rev}:{rev}"));
671        }
672
673        GitReference::Commit(rev) => {
674            refspecs.push(format!("+{rev}:refs/commit/{rev}"));
675        }
676    }
677
678    debug!("Performing a Git fetch for: {remote_url}");
679    let result = match refspec_strategy {
680        RefspecStrategy::All => fetch_with_cli(
681            repo,
682            remote_url,
683            refspecs.as_slice(),
684            tags,
685            disable_ssl,
686            offline,
687        ),
688        RefspecStrategy::First => {
689            // Try each refspec
690            let mut errors = refspecs
691                .iter()
692                .map_while(|refspec| {
693                    let fetch_result = fetch_with_cli(
694                        repo,
695                        remote_url,
696                        std::slice::from_ref(refspec),
697                        tags,
698                        disable_ssl,
699                        offline,
700                    );
701
702                    // Stop after the first success and log failures
703                    match fetch_result {
704                        Err(ref err) => {
705                            debug!("Failed to fetch refspec `{refspec}`: {err}");
706                            Some(fetch_result)
707                        }
708                        Ok(()) => None,
709                    }
710                })
711                .collect::<Vec<_>>();
712
713            if errors.len() == refspecs.len() {
714                if let Some(result) = errors.pop() {
715                    // Use the last error for the message
716                    result
717                } else {
718                    // Can only occur if there were no refspecs to fetch
719                    Ok(())
720                }
721            } else {
722                Ok(())
723            }
724        }
725    };
726    match reference {
727        // With the default branch, adding context is confusing
728        GitReference::DefaultBranch => result,
729        GitReference::Commit(_)
730        | GitReference::Branch(_)
731        | GitReference::Tag(_)
732        | GitReference::BranchOrTag(_)
733        | GitReference::BranchOrTagOrCommit(_)
734        | GitReference::NamedRef(_) => result.with_context(|| {
735            format!(
736                "failed to fetch {} `{}`",
737                reference.kind_str(),
738                reference.as_rev()
739            )
740        }),
741    }
742}
743
744/// Attempts to use `git` CLI installed on the system to fetch a repository.
745fn fetch_with_cli(
746    repo: &mut GitRepository,
747    url: &SafeUrl,
748    refspecs: &[String],
749    tags: bool,
750    disable_ssl: bool,
751    offline: bool,
752) -> Result<()> {
753    let mut cmd = GIT.as_ref().cloned()?;
754    // Disable interactive prompts in the terminal, as they'll be erased by the progress bar
755    // animation and the process will "hang". Interactive prompts via the GUI like `SSH_ASKPASS`
756    // are still usable.
757    cmd.env(EnvVars::GIT_TERMINAL_PROMPT, "0");
758
759    cmd.arg("fetch");
760    if tags {
761        cmd.arg("--tags");
762    }
763    if disable_ssl {
764        debug!("Disabling SSL verification for Git fetch via `GIT_SSL_NO_VERIFY`");
765        cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
766    }
767    if offline {
768        debug!("Disabling remote protocols for Git fetch via `GIT_ALLOW_PROTOCOL=file`");
769        cmd.env(EnvVars::GIT_ALLOW_PROTOCOL, "file");
770    }
771    cmd.arg("--force") // handle force pushes
772        .arg("--update-head-ok") // see discussion in #2078
773        .arg(url.as_str())
774        .args(refspecs)
775        .cwd(&repo.path);
776
777    // We capture the output to avoid streaming it to the user's console during clones.
778    // The required `on...line` callbacks currently do nothing.
779    // The output appears to be included in error messages by default.
780    cmd.exec_with_output().map_err(|err| {
781        let msg = err.to_string();
782        if msg.contains("transport '") && msg.contains("' not allowed") && offline {
783            return GitError::TransportNotAllowed.into();
784        }
785        redact_git_error(err, url)
786    })?;
787
788    Ok(())
789}
790
791/// A global cache of the `git lfs` command.
792///
793/// Returns an error if Git LFS isn't available.
794/// Caching the command allows us to only check if LFS is installed once.
795///
796/// We also support a helper private environment variable to allow
797/// controlling the LFS extension from being loaded for testing purposes.
798/// Once installed, Git will always load `git-lfs` as a built-in alias
799/// which takes priority over loading from `PATH` which prevents us
800/// from shadowing the extension with other means.
801pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
802    if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
803        return Err(anyhow!("Git LFS extension has been forcefully disabled."));
804    }
805
806    let mut cmd = GIT.as_ref()?.clone();
807    cmd.arg("lfs");
808
809    // Run a simple command to verify LFS is installed
810    cmd.clone().arg("version").exec_with_output()?;
811    Ok(cmd)
812});
813
814/// Attempts to use `git-lfs` CLI to fetch required LFS objects for a given revision.
815fn fetch_lfs(
816    repo: &mut GitRepository,
817    url: &SafeUrl,
818    revision: &GitOid,
819    disable_ssl: bool,
820) -> Result<bool> {
821    let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
822        debug!("Fetching Git LFS objects");
823        lfs.clone()
824    } else {
825        // Since this feature is opt-in, warn if not available
826        warn!("Git LFS is not available, skipping LFS fetch");
827        return Ok(false);
828    };
829
830    if disable_ssl {
831        debug!("Disabling SSL verification for Git LFS");
832        cmd.env(EnvVars::GIT_SSL_NO_VERIFY, "true");
833    }
834
835    cmd.arg("fetch")
836        .arg(url.as_str())
837        .arg(revision.as_str())
838        // We should not support requesting LFS artifacts with skip smudge being set.
839        // While this may not be necessary, it's added to avoid any potential future issues.
840        .env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
841        .cwd(&repo.path);
842
843    cmd.exec_with_output()
844        .map_err(|err| redact_git_error(err, url))?;
845
846    // We now validate the Git LFS objects explicitly (if supported). This is
847    // needed to avoid issues with Git LFS not being installed or configured
848    // on the system and giving the wrong impression to the user that Git LFS
849    // objects were initialized correctly when installation finishes.
850    // We may want to allow the user to skip validation in the future via
851    // UV_GIT_LFS_NO_VALIDATION environment variable on rare cases where
852    // validation costs outweigh the benefit.
853    let validation_result = repo.lfs_fsck_objects(revision.as_str());
854
855    Ok(validation_result)
856}
857
858/// Redact a credentialed remote URL from a Git process error.
859fn redact_git_error(mut error: anyhow::Error, url: &SafeUrl) -> anyhow::Error {
860    let credentialed_root = remote_url_root(url.clone());
861    let redact = |message: &str| credentialed_root.redact_in(&url.redact_in(message));
862
863    if let Some(process_error) = error.downcast_mut::<ProcessError>() {
864        process_error.desc = redact(&process_error.desc);
865        return error;
866    }
867
868    anyhow!("{}", redact(&error.to_string()))
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    #[test]
876    fn submodule_update_config_strips_credentials_from_origin_override() {
877        let url = SafeUrl::parse("https://user:password@example.com/org/repo.git").unwrap();
878
879        assert_eq!(
880            submodule_update_config(&url),
881            vec![
882                "remote.origin.url=https://example.com/org/repo.git".to_string(),
883                "url.https://user:password@example.com/.insteadOf=https://example.com/".to_string(),
884            ]
885        );
886    }
887
888    #[test]
889    fn submodule_update_config_preserves_git_ssh_user() {
890        let url = SafeUrl::parse("ssh://git@example.com/org/repo.git").unwrap();
891
892        assert_eq!(
893            submodule_update_config(&url),
894            vec!["remote.origin.url=ssh://git@example.com/org/repo.git".to_string()]
895        );
896    }
897
898    #[test]
899    fn git_process_error_redacts_credentials() -> Result<()> {
900        let url = SafeUrl::parse("https://git:secret-token@example.com/org/repo.git")?;
901        let stderr = format!("fatal: Authentication failed for '{}'", url.as_str());
902        let error = ProcessError::new_raw(
903            &format!(
904                "process didn't exit successfully: `git fetch --force '{}' '+HEAD:refs/remotes/origin/HEAD'`",
905                url.as_str()
906            ),
907            Some(128),
908            "exit status: 128",
909            Some(b"git output"),
910            Some(stderr.as_bytes()),
911        )
912        .into();
913
914        let error = redact_git_error(error, &url);
915        let process_error = error
916            .downcast_ref::<ProcessError>()
917            .context("expected Git process error")?;
918
919        assert_eq!(
920            error.to_string(),
921            "process didn't exit successfully: `git fetch --force 'https://git:****@example.com/org/repo.git' '+HEAD:refs/remotes/origin/HEAD'` (exit status: 128)\n--- stdout\ngit output\n--- stderr\nfatal: Authentication failed for 'https://git:****@example.com/org/repo.git'"
922        );
923        assert_eq!(process_error.code, Some(128));
924        assert_eq!(
925            process_error.stdout.as_deref(),
926            Some(b"git output".as_slice())
927        );
928        assert_eq!(process_error.stderr.as_deref(), Some(stderr.as_bytes()));
929
930        Ok(())
931    }
932
933    #[test]
934    fn git_submodule_process_error_redacts_credentials() -> Result<()> {
935        let url = SafeUrl::parse("https://git:secret-token@example.com/org/repo.git")?;
936
937        for args in ["--init", "--recursive --init"] {
938            let error = anyhow!(
939                "process didn't exit successfully: `git -c 'url.https://git:secret-token@example.com/.insteadOf=https://example.com/' submodule update {args}` (exit status: 128)"
940            );
941            let redacted = redact_git_error(error, &url).to_string();
942
943            assert!(!redacted.contains("secret-token"));
944            assert_eq!(
945                redacted,
946                format!(
947                    "process didn't exit successfully: `git -c 'url.https://git:****@example.com/.insteadOf=https://example.com/' submodule update {args}` (exit status: 128)"
948                )
949            );
950        }
951
952        Ok(())
953    }
954}