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