Skip to main content

foundry_cli/
install.rs

1//! Dependency installation shared by Forge commands.
2
3use crate::{
4    lockfile::{DepIdentifier, DepMap, FOUNDRY_LOCK, Lockfile},
5    opts::{Dependency, DependencyRefKind},
6    utils::{Git, LoadConfig},
7};
8use clap::{Parser, ValueHint};
9use eyre::{Context, Result};
10use foundry_common::fs;
11use foundry_config::{Config, impl_figment_convert_basic};
12use regex::Regex;
13use semver::Version;
14use std::{
15    io::{ErrorKind, IsTerminal},
16    path::{Path, PathBuf},
17    str,
18    sync::LazyLock,
19};
20use yansi::Paint;
21
22#[cfg(feature = "soldeer")]
23use soldeer_commands::{Command, Verbosity, commands::install::Install};
24
25static DEPENDENCY_VERSION_TAG_REGEX: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap());
27
28/// CLI arguments for `forge install`.
29#[derive(Clone, Debug, Parser)]
30#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]...
31    forge install [OPTIONS] <github username>/<github project>@<tag>...
32    forge install [OPTIONS] <alias>=<github username>/<github project>@<tag>...
33    forge install [OPTIONS] <https://<github token>@git url>...)]
34    forge install [OPTIONS] <https:// git url>...")]
35pub struct InstallArgs {
36    /// The dependencies to install.
37    ///
38    /// A dependency can be a raw URL, or the path to a GitHub repository.
39    ///
40    /// Additionally, a ref can be provided by adding @ to the dependency path.
41    ///
42    /// A ref can be:
43    /// - A branch: master
44    /// - A tag: v1.2.3
45    /// - A commit: 8e8128
46    ///
47    /// For exact match, a ref can be provided with `@tag=`, `@branch=` or `@rev=` prefix.
48    ///
49    /// Target installation directory can be added via `<alias>=` suffix.
50    /// The dependency will installed to `lib/<alias>`.
51    dependencies: Vec<Dependency>,
52
53    /// The project's root path.
54    ///
55    /// By default root of the Git repository, if in one,
56    /// or the current working directory.
57    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
58    pub root: Option<PathBuf>,
59
60    /// Do not create a commit after installing.
61    ///
62    /// This is a noop flag kept for backwards compatibility, as `forge install` no longer commits
63    /// by default. Use `--commit` to opt into creating a commit.
64    #[arg(long, hide = true)]
65    pub no_commit: bool,
66
67    #[command(flatten)]
68    opts: DependencyInstallOpts,
69}
70
71impl_figment_convert_basic!(InstallArgs);
72
73impl InstallArgs {
74    pub async fn run(mut self) -> Result<()> {
75        if self.root.is_none() {
76            self.root = std::env::current_dir()?
77                .ancestors()
78                .find(|root| root.join(Config::FILE_NAME).is_file())
79                .map(Path::to_path_buf);
80        }
81        let mut config = self.load_config()?;
82        self.opts.install(&mut config, self.dependencies).await
83    }
84}
85
86#[derive(Clone, Copy, Debug, Default, Parser)]
87pub struct DependencyInstallOpts {
88    /// Perform shallow clones instead of deep ones.
89    ///
90    /// Improves performance and reduces disk usage, but prevents switching branches or tags.
91    #[arg(long)]
92    pub shallow: bool,
93
94    /// Install without adding the dependency as a submodule.
95    #[arg(long)]
96    pub no_git: bool,
97
98    /// Create a commit after installing the dependencies.
99    #[arg(long)]
100    pub commit: bool,
101}
102
103impl DependencyInstallOpts {
104    pub fn git(self, config: &Config) -> Git<'_> {
105        Git::from_config(config).shallow(self.shallow)
106    }
107
108    /// Installs all missing dependencies.
109    ///
110    /// See also [`Self::install`].
111    ///
112    /// Returns true if any dependency was installed.
113    pub fn install_missing_dependencies(self, config: &mut Config) -> bool {
114        let lib = config.install_lib_dir();
115        if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
116            let _ = sh_status!("Missing dependencies found. Installing now...");
117
118            if self.install_existing_dependencies(config).is_err() {
119                let _ =
120                    sh_warn!("Your project has missing dependencies that could not be installed.");
121            }
122            true
123        } else {
124            false
125        }
126    }
127
128    /// Restores existing dependencies without running asynchronous package installation.
129    fn install_existing_dependencies(self, config: &mut Config) -> Result<()> {
130        let git = self.git(config);
131        let install_lib_dir = config.install_lib_dir();
132        let libs = git.root.join(install_lib_dir);
133        let (lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
134
135        if !self.no_git {
136            // Use the root of the git repository to look for submodules.
137            let root = Git::root_of(git.root)?;
138            match git.has_submodules(Some(&root)) {
139                Ok(true) => {
140                    sh_status!("Updating dependencies in {}", libs.display())?;
141
142                    // recursively fetch all submodules (without fetching latest)
143                    git.submodule_update(false, false, false, true, Some(&libs))?;
144
145                    // checkout submodules at the revs recorded in `foundry.lock`
146                    if let Some(out_of_sync) = &out_of_sync_deps {
147                        for (rel_path, dep_id) in out_of_sync {
148                            let path = git.root.join(rel_path);
149                            git.checkout_at(dep_id.checkout_id(), &path).wrap_err_with(|| {
150                                let dep = rel_path.display();
151                                // `sync` also creates entries that are not recorded on disk.
152                                let mut on_disk = Lockfile::new(&config.root);
153                                let hint =
154                                    if on_disk.read().is_ok() && on_disk.get(rel_path).is_some() {
155                                        format!(
156                                            "\n`{dep}` is locked to {dep_id} in {FOUNDRY_LOCK}. If \
157                                             that ref is no longer available from the dependency's \
158                                             remote, update the `{dep}` entry in {FOUNDRY_LOCK}."
159                                        )
160                                    } else if let DepIdentifier::Branch { name, .. } = dep_id {
161                                        format!(
162                                            "\nBranch `{name}` comes from the `branch` setting for \
163                                             `{dep}` in .gitmodules. If it no longer exists on the \
164                                             dependency's remote, update .gitmodules; if it does, \
165                                             the local clone may be missing it, for example after \
166                                             a `--shallow` install."
167                                        )
168                                    } else {
169                                        String::new()
170                                    };
171                                format!(
172                                    "Failed to check out `{}` for dependency `{dep}`{hint}",
173                                    dep_id.checkout_id()
174                                )
175                            })?;
176                        }
177                    }
178
179                    lockfile.write()?;
180                }
181                Err(err) => {
182                    sh_err!("Failed to check for submodules: {err}")?;
183                }
184                _ => {
185                    // no submodules, nothing to do
186                }
187            }
188        }
189
190        fs::create_dir_all(&libs)?;
191
192        // update `libs` in config if not included yet
193        if !config.libs.iter().any(|p| p == install_lib_dir) {
194            config.libs.push(install_lib_dir.to_path_buf());
195            config.update_libs()?;
196        }
197
198        Ok(())
199    }
200
201    fn sync_lockfile<'a>(
202        self,
203        config: &Config,
204        git: &'a Git<'_>,
205    ) -> Result<(Lockfile<'a>, Option<DepMap>)> {
206        let libs = git.root.join(config.install_lib_dir());
207        let mut lockfile = Lockfile::new(&config.root);
208        if !self.no_git {
209            lockfile = lockfile.with_git(git);
210
211            // Check if submodules are uninitialized, if so, we need to fetch all submodules
212            // This is to ensure that foundry.lock syncs successfully and doesn't error out, when
213            // looking for commits/tags in submodules
214            if git.submodules_uninitialized()? {
215                trace!(lib = %libs.display(), "submodules uninitialized");
216                git.submodule_update(false, false, false, true, Some(&libs))?;
217            }
218        }
219
220        let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
221
222        Ok((lockfile, out_of_sync_deps))
223    }
224
225    /// Installs all dependencies
226    pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
227        if dependencies.is_empty() {
228            return self.install_existing_dependencies(config);
229        }
230
231        let Self { no_git, commit, .. } = self;
232
233        let git = self.git(config);
234
235        let install_lib_dir = config.install_lib_dir();
236        let libs = git.root.join(install_lib_dir);
237
238        let (mut lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
239
240        fs::create_dir_all(&libs)?;
241
242        let installer = Installer { git, commit };
243        for dep in dependencies {
244            if dep
245                .name()
246                .split(['/', '\\'])
247                .any(|component| component.is_empty() || matches!(component, "." | ".."))
248            {
249                eyre::bail!("invalid dependency name: {}", dep.name());
250            }
251            let path = libs.join(dep.name());
252            let rel_path = path
253                .strip_prefix(git.root)
254                .wrap_err("Library directory is not relative to the repository root")?;
255            sh_status!(
256                "Installing {} in {} (url: {}, tag: {})",
257                dep.name,
258                path.display(),
259                dep.url.as_deref().unwrap_or("None"),
260                dep.tag.as_deref().unwrap_or("None")
261            )?;
262
263            // this tracks the actual installed tag
264            let installed_tag;
265            let mut dep_id = None;
266            if no_git {
267                installed_tag = installer.install_as_folder(&dep, &path)?;
268            } else {
269                if commit {
270                    git.ensure_clean()?;
271                }
272                installed_tag = installer.install_as_submodule(&dep, &path)?;
273
274                let mut new_insertion = false;
275                // Pin branch to submodule if branch is used
276                if let Some(tag_or_branch) = &installed_tag {
277                    dep_id = Some(match dep.ref_kind {
278                        Some(DependencyRefKind::Branch) => DepIdentifier::Branch {
279                            name: tag_or_branch.clone(),
280                            rev: git.root(&path).head()?,
281                            r#override: false,
282                        },
283                        Some(DependencyRefKind::Tag) => DepIdentifier::Tag {
284                            name: tag_or_branch.clone(),
285                            rev: git.root(&path).head()?,
286                            r#override: false,
287                        },
288                        Some(DependencyRefKind::Rev) => {
289                            DepIdentifier::Rev { rev: git.root(&path).head()?, r#override: false }
290                        }
291                        None => DepIdentifier::resolve_type(&git, &path, tag_or_branch)?,
292                    });
293                    if git.has_branch(tag_or_branch, &path)?
294                        && dep_id.as_ref().is_some_and(|id| id.is_branch())
295                    {
296                        // always work with relative paths when directly modifying submodules
297                        git.set_submodule_branch(rel_path, tag_or_branch)?;
298                        let root = Git::root_of(git.root)?;
299                        git.root(&root).add_literal(Path::new(".gitmodules"))?;
300                    }
301
302                    trace!(?dep_id, ?tag_or_branch, "resolved dep id");
303                    if let Some(dep_id) = &dep_id {
304                        new_insertion = true;
305                        lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
306                    }
307
308                    if commit {
309                        // update .gitmodules which is at the root of the repo,
310                        // not necessarily at the root of the current Foundry project
311                        let root = Git::root_of(git.root)?;
312                        git.root(&root).add(Some(".gitmodules"))?;
313                    }
314                }
315
316                if new_insertion
317                    || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
318                    || !lockfile.exists()
319                {
320                    lockfile.write()?;
321                }
322
323                // commit the installation
324                if commit {
325                    let mut msg = String::with_capacity(128);
326                    msg.push_str("forge install: ");
327                    msg.push_str(dep.name());
328
329                    if let Some(tag) = &installed_tag {
330                        msg.push_str("\n\n");
331
332                        if let Some(dep_id) = &dep_id {
333                            msg.push_str(&dep_id.to_string());
334                        } else {
335                            msg.push_str(tag);
336                        }
337                    }
338
339                    if !lockfile.is_empty() {
340                        git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
341                    }
342                    git.commit(&msg)?;
343                }
344            }
345
346            let mut msg = format!("    {} {}", "Installed".green(), dep.name);
347            if let Some(tag) = dep.tag.or(installed_tag) {
348                msg.push(' ');
349
350                if let Some(dep_id) = dep_id {
351                    msg.push_str(&dep_id.to_string());
352                } else {
353                    msg.push_str(tag.as_str());
354                }
355            }
356            sh_status!("{msg}")?;
357
358            // Check if the dependency has soldeer.lock and install soldeer dependencies
359            install_soldeer_deps_if_needed(&path).await.map_err(|e| {
360                eyre::eyre!(
361                    "Failed to install soldeer dependencies for {}: {e}\n\
362                     Run `forge soldeer install` in {} to retry.",
363                    dep.name,
364                    path.display()
365                )
366            })?;
367        }
368
369        // update `libs` in config if not included yet
370        if !config.libs.iter().any(|p| p == install_lib_dir) {
371            config.libs.push(install_lib_dir.to_path_buf());
372            config.update_libs()?;
373        }
374
375        Ok(())
376    }
377}
378
379/// Installs missing dependencies and reloads config only to discover new remappings.
380pub fn install_missing_dependencies<E>(
381    config: &mut Config,
382    reload: impl FnOnce() -> Result<Config, E>,
383) -> Result<(), E> {
384    if DependencyInstallOpts::default().install_missing_dependencies(config)
385        && config.auto_detect_remappings
386    {
387        *config = reload()?;
388    }
389    Ok(())
390}
391
392/// Checks if a dependency has soldeer.lock and installs soldeer dependencies if needed.
393#[cfg(feature = "soldeer")]
394async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
395    let soldeer_lock = dep_path.join("soldeer.lock");
396
397    if soldeer_lock.exists() {
398        sh_status!("    Found soldeer.lock, installing soldeer dependencies...")?;
399
400        // Change to the dependency directory and run soldeer install
401        let original_dir = std::env::current_dir()?;
402        std::env::set_current_dir(dep_path)?;
403
404        let result = soldeer_commands::run(
405            Command::Install(Install::default()),
406            Verbosity::new(
407                foundry_common::shell::verbosity(),
408                if foundry_common::shell::is_quiet() { 1 } else { 0 },
409            ),
410        )
411        .await;
412
413        // Change back to original directory
414        std::env::set_current_dir(original_dir)?;
415
416        result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
417        sh_status!("    Soldeer dependencies installed successfully")?;
418    }
419
420    Ok(())
421}
422
423/// Warns that soldeer dependencies are not installed because soldeer support is not compiled in.
424#[cfg(not(feature = "soldeer"))]
425async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
426    if dep_path.join("soldeer.lock").exists() {
427        sh_warn!(
428            "    Found soldeer.lock, but this build does not include soldeer; \
429             skipping soldeer dependencies"
430        )?;
431    }
432
433    Ok(())
434}
435
436#[derive(Clone, Copy, Debug)]
437struct Installer<'a> {
438    git: Git<'a>,
439    commit: bool,
440}
441
442struct NewSubmoduleGuard {
443    root: PathBuf,
444    relative_path: PathBuf,
445    path: PathBuf,
446    module_dir: PathBuf,
447    gitmodules_contents: Option<Vec<u8>>,
448    armed: bool,
449}
450
451impl NewSubmoduleGuard {
452    const fn disarm(&mut self) {
453        self.armed = false;
454    }
455
456    fn rollback(&self) {
457        let git = Git::new(&self.root);
458        if let Err(err) = git.remove_index_path(&self.relative_path) {
459            warn!(%err, "failed to remove submodule after installation failure");
460        }
461        if self.path.exists()
462            && let Err(err) = fs::remove_dir_all(&self.path)
463        {
464            warn!(%err, "failed to remove dependency after installation failure");
465        }
466        if self.module_dir.exists()
467            && let Err(err) = fs::remove_dir_all(&self.module_dir)
468        {
469            warn!(%err, "failed to remove submodule Git directory after installation failure");
470        }
471        if let Err(err) = git.remove_submodule_config(&self.relative_path) {
472            warn!(%err, "failed to remove submodule config after installation failure");
473        }
474        restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref());
475        if let Err(err) = git.add_literal(Path::new(".gitmodules")) {
476            warn!(%err, "failed to restore staged .gitmodules after installation failure");
477        }
478    }
479}
480
481impl Drop for NewSubmoduleGuard {
482    fn drop(&mut self) {
483        if self.armed {
484            self.rollback();
485        }
486    }
487}
488
489/// Removes a newly created dependency directory if its installation fails.
490struct NewFolderGuard<'a> {
491    path: &'a Path,
492    armed: bool,
493}
494
495impl<'a> NewFolderGuard<'a> {
496    fn new(path: &'a Path) -> Result<Self> {
497        if let Some(parent) = path.parent() {
498            fs::create_dir_all(parent)?;
499        }
500        // Claim the destination before cloning so rollback cannot remove an existing path.
501        fs::create_dir(path)?;
502        Ok(Self { path, armed: true })
503    }
504
505    const fn disarm(&mut self) {
506        self.armed = false;
507    }
508}
509
510impl Drop for NewFolderGuard<'_> {
511    fn drop(&mut self) {
512        if self.armed
513            && let Err(err) = std::fs::remove_dir_all(self.path)
514            && err.kind() != ErrorKind::NotFound
515        {
516            warn!(%err, path = %self.path.display(), "failed to remove dependency after installation failure");
517        }
518    }
519}
520
521fn restore_file(path: &Path, contents: Option<&[u8]>) {
522    let result = match contents {
523        Some(contents) => fs::write(path, contents),
524        None if path.exists() => fs::remove_file(path),
525        None => Ok(()),
526    };
527    if let Err(err) = result {
528        warn!(%err, path = %path.display(), "failed to restore file after installation failure");
529    }
530}
531
532impl Installer<'_> {
533    /// Installs the dependency as an ordinary folder instead of a submodule
534    fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
535        let url = dep.require_url()?;
536        let mut guard = NewFolderGuard::new(path)?;
537        Git::clone(dep.tag.is_none(), url, Some(&path))?;
538        let mut dep = dep.clone();
539
540        if dep.tag.is_none() {
541            // try to find latest semver release tag
542            dep.tag = self.last_tag(path);
543        }
544
545        // checkout the tag if necessary, using recursive checkout to properly clean up
546        // nested submodules that may exist on the default branch but not on the target tag.
547        // See: https://github.com/foundry-rs/foundry/issues/13688
548        self.git_checkout(&dep, path, true)?;
549
550        trace!("updating dependency submodules recursively");
551        self.git.root(path).submodule_update(
552            false,
553            false,
554            false,
555            true,
556            std::iter::empty::<PathBuf>(),
557        )?;
558
559        // remove nested .git directories from submodules before removing the top-level .git
560        Self::remove_nested_git_dirs(path)?;
561
562        // remove git artifacts
563        fs::remove_dir_all(path.join(".git"))?;
564
565        guard.disarm();
566        Ok(dep.tag)
567    }
568
569    /// Recursively removes `.git` files/directories from nested submodules within `root`.
570    ///
571    /// Submodules typically have a `.git` file (not a directory) pointing to the parent's
572    /// `.git/modules/` directory. This cleans those up so the result is a plain folder tree.
573    fn remove_nested_git_dirs(root: &Path) -> Result<()> {
574        Self::remove_nested_git_dirs_inner(root, root)
575    }
576
577    fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
578        let entries = match std::fs::read_dir(dir) {
579            Ok(entries) => entries,
580            Err(_) => return Ok(()),
581        };
582        for entry in entries {
583            let entry = entry?;
584            let ft = entry.file_type()?;
585
586            // never follow symlinks
587            if ft.is_symlink() {
588                continue;
589            }
590
591            let path = entry.path();
592            if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
593                if ft.is_dir() {
594                    fs::remove_dir_all(&path)?;
595                } else {
596                    fs::remove_file(&path)?;
597                }
598            } else if ft.is_dir() {
599                Self::remove_nested_git_dirs_inner(root, &path)?;
600            }
601        }
602        Ok(())
603    }
604
605    /// Installs the dependency as new submodule.
606    ///
607    /// This will add the git submodule to the given dir, initialize it and checkout the tag if
608    /// provided or try to find the latest semver, release tag.
609    fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
610        let root = Git::root_of(self.git.root)?;
611        let relative_path = path.strip_prefix(&root)?;
612        let git = self.git.root(&root);
613        let gitmodules = root.join(".gitmodules");
614        let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?;
615        let has_mapping = git.has_submodule_mapping(relative_path)?;
616        let is_gitlink = git.is_gitlink(relative_path)?;
617        if has_mapping != is_gitlink {
618            eyre::bail!(
619                "cannot safely install dependency at {} because .gitmodules already contains a matching submodule",
620                relative_path.display()
621            );
622        }
623        let mut guard = if is_gitlink {
624            None
625        } else {
626            let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path);
627            let gitmodules_safe = !gitmodules.is_symlink()
628                && if gitmodules.exists() {
629                    git.is_normal_tracked_file(Path::new(".gitmodules"))?
630                } else {
631                    !git.has_index_entries(Path::new(".gitmodules"))?
632                };
633            let can_rollback = !path.is_symlink()
634                && !path.exists()
635                && !module_dir.exists()
636                && !git.has_index_entries(relative_path)?
637                && !git.has_submodule_config(relative_path)?
638                && git.is_path_clean(relative_path)?
639                && gitmodules_safe;
640            if !can_rollback {
641                eyre::bail!(
642                    "cannot safely install dependency at {} because the target or .gitmodules has existing changes",
643                    relative_path.display()
644                );
645            }
646            Some(NewSubmoduleGuard {
647                root,
648                relative_path: relative_path.to_path_buf(),
649                path: path.to_path_buf(),
650                module_dir,
651                gitmodules_contents,
652                armed: true,
653            })
654        };
655
656        // install the dep
657        self.git_submodule(dep, path)?;
658
659        let mut dep = dep.clone();
660        if dep.tag.is_none() {
661            // try to find latest semver release tag
662            dep.tag = self.last_tag(path);
663        }
664
665        // checkout the tag if necessary
666        self.git_checkout(&dep, path, true)?;
667
668        trace!("updating dependency submodules recursively");
669        self.git.root(path).submodule_update(
670            false,
671            false,
672            false,
673            true,
674            std::iter::empty::<PathBuf>(),
675        )?;
676
677        // sync submodules config with changes in .gitmodules, see <https://github.com/foundry-rs/foundry/issues/9611>
678        self.git.root(path).submodule_sync()?;
679
680        if let Some(guard) = &mut guard {
681            guard.disarm();
682        }
683        if self.commit {
684            self.git.add_literal(path)?;
685        }
686
687        Ok(dep.tag)
688    }
689
690    fn last_tag(self, path: &Path) -> Option<String> {
691        if self.git.shallow {
692            None
693        } else {
694            self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
695        }
696    }
697
698    /// Returns all semver git tags sorted in ascending order
699    fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
700        let out = self.git.root(path).tag()?;
701        let mut tags = Vec::new();
702        // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic
703        // version
704        let common_prefixes = &["v-", "v", "release-", "release"];
705        for tag in out.lines() {
706            let mut maybe_semver = tag;
707            for &prefix in common_prefixes {
708                if let Some(rem) = tag.strip_prefix(prefix) {
709                    maybe_semver = rem;
710                    break;
711                }
712            }
713            match Version::parse(maybe_semver) {
714                Ok(v) => {
715                    // ignore if additional metadata, like rc, beta, etc...
716                    if v.build.is_empty() && v.pre.is_empty() {
717                        tags.push((tag.to_string(), v));
718                    }
719                }
720                Err(err) => {
721                    warn!(?err, ?maybe_semver, "No semver tag");
722                }
723            }
724        }
725
726        tags.sort_by(|(_, a), (_, b)| a.cmp(b));
727
728        Ok(tags)
729    }
730
731    /// Install the given dependency as git submodule in `target_dir`.
732    fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
733        let url = dep.require_url()?;
734
735        // make path relative to the git root, already checked above
736        let path = path.strip_prefix(self.git.root).unwrap();
737
738        trace!(?dep, url, ?path, "installing git submodule");
739        self.git.submodule_add(true, url, path)
740    }
741
742    fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<()> {
743        // no need to checkout if there is no tag
744        let Some(mut tag) = dep.tag.clone() else { return Ok(()) };
745
746        // Only disambiguate unqualified refs in interactive terminals.
747        if dep.ref_kind.is_none() && std::io::stdout().is_terminal() {
748            if tag.is_empty() {
749                tag = self.match_tag(&tag, path)?;
750            } else if let Some(branch) = self.match_branch(&tag, path)? {
751                trace!(?tag, ?branch, "selecting branch for given tag");
752                tag = branch;
753            }
754        }
755        let url = dep.url.as_ref().unwrap();
756
757        // Preserve explicit ref kinds. Unqualified tags use their full ref so a local branch
758        // with the same name cannot shadow them.
759        let checkout_ref = match dep.ref_kind {
760            Some(DependencyRefKind::Tag) => format!("refs/tags/{tag}"),
761            Some(DependencyRefKind::Branch | DependencyRefKind::Rev) => tag.clone(),
762            None if dep.tag.as_deref() == Some(tag.as_str()) && self.git.has_tag(&tag, path)? => {
763                format!("refs/tags/{tag}")
764            }
765            None => tag.clone(),
766        };
767        let res = if dep.ref_kind == Some(DependencyRefKind::Branch) {
768            self.git.root(path).checkout_branch(recurse, &tag)
769        } else {
770            self.git.root(path).checkout(recurse, checkout_ref)
771        };
772        if let Err(mut e) = res {
773            // remove dependency on failed checkout
774            fs::remove_dir_all(path)?;
775            let error = e.to_string();
776            if error.contains("did not match any file(s) known to git")
777                || error.contains("fatal: invalid reference:")
778            {
779                e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
780            }
781            return Err(e);
782        }
783
784        Ok(())
785    }
786
787    /// disambiguate tag if it is a version tag
788    fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
789        // only try to match if it looks like a version tag
790        if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
791            return Ok(tag.into());
792        }
793
794        // generate candidate list by filtering `git tag` output, valid ones are those "starting
795        // with" the user-provided tag (ignoring the starting 'v'), for example, if the user
796        // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not
797        let trimmed_tag = tag.trim_start_matches('v').to_string();
798        let output = self.git.root(path).tag()?;
799        let mut candidates: Vec<String> = output
800            .trim()
801            .lines()
802            .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
803            .map(|x| x.to_string())
804            .rev()
805            .collect();
806
807        // no match found, fall back to the user-provided tag
808        if candidates.is_empty() {
809            return Ok(tag.into());
810        }
811
812        // have exact match
813        for candidate in &candidates {
814            if candidate == tag {
815                return Ok(tag.into());
816            }
817        }
818
819        // only one candidate, ask whether the user wants to accept or not
820        if candidates.len() == 1 {
821            let matched_tag = &candidates[0];
822            let input = prompt!(
823                "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
824            )?;
825            return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
826        }
827
828        // multiple candidates, ask the user to choose one or skip
829        candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
830        sh_status!("There are multiple matching tags:")?;
831        for (i, candidate) in candidates.iter().enumerate() {
832            sh_status!("[{i}] {candidate}")?;
833        }
834
835        let n_candidates = candidates.len();
836        loop {
837            let input: String =
838                prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
839            let s = input.trim();
840            // default selection, return first candidate
841            let n = if s.is_empty() { Ok(1) } else { s.parse() };
842            // match user input, 0 indicates skipping and use original tag
843            match n {
844                Ok(0) => return Ok(tag.into()),
845                Ok(i) if (1..=n_candidates).contains(&i) => {
846                    let c = &candidates[i];
847                    sh_status!("[{i}] {c} selected")?;
848                    return Ok(c.clone());
849                }
850                _ => {}
851            }
852        }
853    }
854
855    fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
856        // fetch remote branches and check for tag
857        let output = self.git.root(path).remote_branches()?;
858
859        let mut candidates = output
860            .lines()
861            .map(|x| x.trim().trim_start_matches("origin/"))
862            .filter(|x| x.starts_with(tag))
863            .map(ToString::to_string)
864            .rev()
865            .collect::<Vec<_>>();
866
867        trace!(?candidates, ?tag, "found branch candidates");
868
869        // no match found, fall back to the user-provided tag
870        if candidates.is_empty() {
871            return Ok(None);
872        }
873
874        // have exact match
875        for candidate in &candidates {
876            if candidate == tag {
877                return Ok(Some(tag.to_string()));
878            }
879        }
880
881        // only one candidate, ask whether the user wants to accept or not
882        if candidates.len() == 1 {
883            let matched_tag = &candidates[0];
884            let input = prompt!(
885                "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
886            )?;
887            return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
888        }
889
890        // multiple candidates, ask the user to choose one or skip
891        candidates.insert(0, format!("{tag} (original branch)"));
892        sh_status!("There are multiple matching branches:")?;
893        for (i, candidate) in candidates.iter().enumerate() {
894            sh_status!("[{i}] {candidate}")?;
895        }
896
897        let n_candidates = candidates.len();
898        let input: String = prompt!(
899            "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
900            n_candidates - 1
901        )?;
902        let input = input.trim();
903
904        // default selection, return None
905        if input.is_empty() {
906            sh_status!("Canceled branch matching")?;
907            return Ok(None);
908        }
909
910        // match user input, 0 indicates skipping and use original tag
911        match input.parse::<usize>() {
912            Ok(0) => Ok(Some(tag.into())),
913            Ok(i) if (1..=n_candidates).contains(&i) => {
914                let c = &candidates[i];
915                sh_status!("[{i}] {c} selected")?;
916                Ok(Some(c.clone()))
917            }
918            _ => Ok(None),
919        }
920    }
921}
922
923/// Matches on the result of a prompt for yes/no.
924///
925/// Defaults to true.
926fn match_yn(input: String) -> bool {
927    let s = input.trim().to_lowercase();
928    matches!(s.as_str(), "" | "y" | "yes")
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use tempfile::tempdir;
935
936    #[test]
937    #[ignore = "slow"]
938    fn get_oz_tags() {
939        let tmp = tempdir().unwrap();
940        let git = Git::new(tmp.path());
941        let installer = Installer { git, commit: false };
942
943        git.init().unwrap();
944
945        let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
946        let libs = tmp.path().join("libs");
947        fs::create_dir(&libs).unwrap();
948        let submodule = libs.join("openzeppelin-contracts");
949        installer.git_submodule(&dep, &submodule).unwrap();
950        assert!(submodule.exists());
951
952        let tags = installer.git_semver_tags(&submodule).unwrap();
953        assert!(!tags.is_empty());
954        let v480: Version = "4.8.0".parse().unwrap();
955        assert!(tags.iter().any(|(_, v)| v == &v480));
956    }
957}