Skip to main content

forge/cmd/
install.rs

1use crate::{DepIdentifier, FOUNDRY_LOCK, Lockfile};
2use clap::{Parser, ValueHint};
3use eyre::{Context, Result};
4use foundry_cli::{
5    opts::Dependency,
6    utils::{CommandUtils, Git, LoadConfig},
7};
8use foundry_common::fs;
9use foundry_config::{Config, impl_figment_convert_basic};
10use regex::Regex;
11use semver::Version;
12use soldeer_commands::{Command, Verbosity, commands::install::Install};
13use std::{
14    io::IsTerminal,
15    path::{Path, PathBuf},
16    str,
17    sync::LazyLock,
18};
19use yansi::Paint;
20
21static DEPENDENCY_VERSION_TAG_REGEX: LazyLock<Regex> =
22    LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap());
23
24/// CLI arguments for `forge install`.
25#[derive(Clone, Debug, Parser)]
26#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]...
27    forge install [OPTIONS] <github username>/<github project>@<tag>...
28    forge install [OPTIONS] <alias>=<github username>/<github project>@<tag>...
29    forge install [OPTIONS] <https://<github token>@git url>...)]
30    forge install [OPTIONS] <https:// git url>...")]
31pub struct InstallArgs {
32    /// The dependencies to install.
33    ///
34    /// A dependency can be a raw URL, or the path to a GitHub repository.
35    ///
36    /// Additionally, a ref can be provided by adding @ to the dependency path.
37    ///
38    /// A ref can be:
39    /// - A branch: master
40    /// - A tag: v1.2.3
41    /// - A commit: 8e8128
42    ///
43    /// For exact match, a ref can be provided with `@tag=`, `@branch=` or `@rev=` prefix.
44    ///
45    /// Target installation directory can be added via `<alias>=` suffix.
46    /// The dependency will installed to `lib/<alias>`.
47    dependencies: Vec<Dependency>,
48
49    /// The project's root path.
50    ///
51    /// By default root of the Git repository, if in one,
52    /// or the current working directory.
53    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
54    pub root: Option<PathBuf>,
55
56    #[command(flatten)]
57    opts: DependencyInstallOpts,
58}
59
60impl_figment_convert_basic!(InstallArgs);
61
62impl InstallArgs {
63    pub async fn run(self) -> Result<()> {
64        let mut config = self.load_config()?;
65        self.opts.install(&mut config, self.dependencies).await
66    }
67}
68
69#[derive(Clone, Copy, Debug, Default, Parser)]
70pub struct DependencyInstallOpts {
71    /// Perform shallow clones instead of deep ones.
72    ///
73    /// Improves performance and reduces disk usage, but prevents switching branches or tags.
74    #[arg(long)]
75    pub shallow: bool,
76
77    /// Install without adding the dependency as a submodule.
78    #[arg(long)]
79    pub no_git: bool,
80
81    /// Create a commit after installing the dependencies.
82    #[arg(long)]
83    pub commit: bool,
84}
85
86impl DependencyInstallOpts {
87    pub fn git(self, config: &Config) -> Git<'_> {
88        Git::from_config(config).shallow(self.shallow)
89    }
90
91    /// Installs all missing dependencies.
92    ///
93    /// See also [`Self::install`].
94    ///
95    /// Returns true if any dependency was installed.
96    pub async fn install_missing_dependencies(self, config: &mut Config) -> bool {
97        let lib = config.install_lib_dir();
98        if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
99            // The extra newline is needed, otherwise the compiler output will overwrite the message
100            let _ = sh_println!("Missing dependencies found. Installing now...\n");
101
102            if self.install(config, Vec::new()).await.is_err() {
103                let _ =
104                    sh_warn!("Your project has missing dependencies that could not be installed.");
105            }
106            true
107        } else {
108            false
109        }
110    }
111
112    /// Installs all dependencies
113    pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
114        let Self { no_git, commit, .. } = self;
115
116        let git = self.git(config);
117
118        let install_lib_dir = config.install_lib_dir();
119        let libs = git.root.join(install_lib_dir);
120
121        let mut lockfile = Lockfile::new(&config.root);
122        if !no_git {
123            lockfile = lockfile.with_git(&git);
124
125            // Check if submodules are uninitialized, if so, we need to fetch all submodules
126            // This is to ensure that foundry.lock syncs successfully and doesn't error out, when
127            // looking for commits/tags in submodules
128            if git.submodules_uninitialized()? {
129                trace!(lib = %libs.display(), "submodules uninitialized");
130                git.submodule_update(false, false, false, true, Some(&libs))?;
131            }
132        }
133
134        let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
135
136        if dependencies.is_empty() && !no_git {
137            // Use the root of the git repository to look for submodules.
138            let root = Git::root_of(git.root)?;
139            match git.has_submodules(Some(&root)) {
140                Ok(true) => {
141                    sh_println!("Updating dependencies in {}", libs.display())?;
142
143                    // recursively fetch all submodules (without fetching latest)
144                    git.submodule_update(false, false, false, true, Some(&libs))?;
145                    lockfile.write()?;
146                }
147                Err(err) => {
148                    sh_err!("Failed to check for submodules: {err}")?;
149                }
150                _ => {
151                    // no submodules, nothing to do
152                }
153            }
154        }
155
156        fs::create_dir_all(&libs)?;
157
158        let installer = Installer { git, commit };
159        for dep in dependencies {
160            let path = libs.join(dep.name());
161            let rel_path = path
162                .strip_prefix(git.root)
163                .wrap_err("Library directory is not relative to the repository root")?;
164            sh_println!(
165                "Installing {} in {} (url: {}, tag: {})",
166                dep.name,
167                path.display(),
168                dep.url.as_deref().unwrap_or("None"),
169                dep.tag.as_deref().unwrap_or("None")
170            )?;
171
172            // this tracks the actual installed tag
173            let installed_tag;
174            let mut dep_id = None;
175            if no_git {
176                installed_tag = installer.install_as_folder(&dep, &path)?;
177            } else {
178                if commit {
179                    git.ensure_clean()?;
180                }
181                installed_tag = installer.install_as_submodule(&dep, &path)?;
182
183                let mut new_insertion = false;
184                // Pin branch to submodule if branch is used
185                if let Some(tag_or_branch) = &installed_tag {
186                    // First, check if this tag has a branch
187                    dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
188                    if git.has_branch(tag_or_branch, &path)?
189                        && dep_id.as_ref().is_some_and(|id| id.is_branch())
190                    {
191                        // always work with relative paths when directly modifying submodules
192                        git.cmd()
193                            .args(["submodule", "set-branch", "-b", tag_or_branch])
194                            .arg(rel_path)
195                            .exec()?;
196
197                        let rev = git.get_rev(tag_or_branch, &path)?;
198
199                        dep_id = Some(DepIdentifier::Branch {
200                            name: tag_or_branch.to_string(),
201                            rev,
202                            r#override: false,
203                        });
204                    }
205
206                    trace!(?dep_id, ?tag_or_branch, "resolved dep id");
207                    if let Some(dep_id) = &dep_id {
208                        new_insertion = true;
209                        lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
210                    }
211
212                    if commit {
213                        // update .gitmodules which is at the root of the repo,
214                        // not necessarily at the root of the current Foundry project
215                        let root = Git::root_of(git.root)?;
216                        git.root(&root).add(Some(".gitmodules"))?;
217                    }
218                }
219
220                if new_insertion
221                    || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
222                    || !lockfile.exists()
223                {
224                    lockfile.write()?;
225                }
226
227                // commit the installation
228                if commit {
229                    let mut msg = String::with_capacity(128);
230                    msg.push_str("forge install: ");
231                    msg.push_str(dep.name());
232
233                    if let Some(tag) = &installed_tag {
234                        msg.push_str("\n\n");
235
236                        if let Some(dep_id) = &dep_id {
237                            msg.push_str(&dep_id.to_string());
238                        } else {
239                            msg.push_str(tag);
240                        }
241                    }
242
243                    if !lockfile.is_empty() {
244                        git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
245                    }
246                    git.commit(&msg)?;
247                }
248            }
249
250            let mut msg = format!("    {} {}", "Installed".green(), dep.name);
251            if let Some(tag) = dep.tag.or(installed_tag) {
252                msg.push(' ');
253
254                if let Some(dep_id) = dep_id {
255                    msg.push_str(&dep_id.to_string());
256                } else {
257                    msg.push_str(tag.as_str());
258                }
259            }
260            sh_println!("{msg}")?;
261
262            // Check if the dependency has soldeer.lock and install soldeer dependencies
263            if let Err(e) = install_soldeer_deps_if_needed(&path).await {
264                sh_warn!("Failed to install soldeer dependencies for {}: {e}", dep.name)?;
265            }
266        }
267
268        // update `libs` in config if not included yet
269        if !config.libs.iter().any(|p| p == install_lib_dir) {
270            config.libs.push(install_lib_dir.to_path_buf());
271            config.update_libs()?;
272        }
273
274        Ok(())
275    }
276}
277
278pub async fn install_missing_dependencies(config: &mut Config) -> bool {
279    DependencyInstallOpts::default().install_missing_dependencies(config).await
280}
281
282/// Checks if a dependency has soldeer.lock and installs soldeer dependencies if needed.
283async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
284    let soldeer_lock = dep_path.join("soldeer.lock");
285
286    if soldeer_lock.exists() {
287        sh_println!("    Found soldeer.lock, installing soldeer dependencies...")?;
288
289        // Change to the dependency directory and run soldeer install
290        let original_dir = std::env::current_dir()?;
291        std::env::set_current_dir(dep_path)?;
292
293        let result = soldeer_commands::run(
294            Command::Install(Install::default()),
295            Verbosity::new(
296                foundry_common::shell::verbosity(),
297                if foundry_common::shell::is_quiet() { 1 } else { 0 },
298            ),
299        )
300        .await;
301
302        // Change back to original directory
303        std::env::set_current_dir(original_dir)?;
304
305        result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
306        sh_println!("    Soldeer dependencies installed successfully")?;
307    }
308
309    Ok(())
310}
311
312#[derive(Clone, Copy, Debug)]
313struct Installer<'a> {
314    git: Git<'a>,
315    commit: bool,
316}
317
318impl Installer<'_> {
319    /// Installs the dependency as an ordinary folder instead of a submodule
320    fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
321        let url = dep.require_url()?;
322        Git::clone(dep.tag.is_none(), url, Some(&path))?;
323        let mut dep = dep.clone();
324
325        if dep.tag.is_none() {
326            // try to find latest semver release tag
327            dep.tag = self.last_tag(path);
328        }
329
330        // checkout the tag if necessary, using recursive checkout to properly clean up
331        // nested submodules that may exist on the default branch but not on the target tag.
332        // See: https://github.com/foundry-rs/foundry/issues/13688
333        self.git_checkout(&dep, path, true)?;
334
335        trace!("updating dependency submodules recursively");
336        self.git.root(path).submodule_update(
337            false,
338            false,
339            false,
340            true,
341            std::iter::empty::<PathBuf>(),
342        )?;
343
344        // remove nested .git directories from submodules before removing the top-level .git
345        Self::remove_nested_git_dirs(path)?;
346
347        // remove git artifacts
348        fs::remove_dir_all(path.join(".git"))?;
349
350        Ok(dep.tag)
351    }
352
353    /// Recursively removes `.git` files/directories from nested submodules within `root`.
354    ///
355    /// Submodules typically have a `.git` file (not a directory) pointing to the parent's
356    /// `.git/modules/` directory. This cleans those up so the result is a plain folder tree.
357    fn remove_nested_git_dirs(root: &Path) -> Result<()> {
358        Self::remove_nested_git_dirs_inner(root, root)
359    }
360
361    fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
362        let entries = match std::fs::read_dir(dir) {
363            Ok(entries) => entries,
364            Err(_) => return Ok(()),
365        };
366        for entry in entries {
367            let entry = entry?;
368            let ft = entry.file_type()?;
369
370            // never follow symlinks
371            if ft.is_symlink() {
372                continue;
373            }
374
375            let path = entry.path();
376            if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
377                if ft.is_dir() {
378                    fs::remove_dir_all(&path)?;
379                } else {
380                    fs::remove_file(&path)?;
381                }
382            } else if ft.is_dir() {
383                Self::remove_nested_git_dirs_inner(root, &path)?;
384            }
385        }
386        Ok(())
387    }
388
389    /// Installs the dependency as new submodule.
390    ///
391    /// This will add the git submodule to the given dir, initialize it and checkout the tag if
392    /// provided or try to find the latest semver, release tag.
393    fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
394        // install the dep
395        self.git_submodule(dep, path)?;
396
397        let mut dep = dep.clone();
398        if dep.tag.is_none() {
399            // try to find latest semver release tag
400            dep.tag = self.last_tag(path);
401        }
402
403        // checkout the tag if necessary
404        self.git_checkout(&dep, path, true)?;
405
406        trace!("updating dependency submodules recursively");
407        self.git.root(path).submodule_update(
408            false,
409            false,
410            false,
411            true,
412            std::iter::empty::<PathBuf>(),
413        )?;
414
415        // sync submodules config with changes in .gitmodules, see <https://github.com/foundry-rs/foundry/issues/9611>
416        self.git.root(path).submodule_sync()?;
417
418        if self.commit {
419            self.git.add(Some(path))?;
420        }
421
422        Ok(dep.tag)
423    }
424
425    fn last_tag(self, path: &Path) -> Option<String> {
426        if self.git.shallow {
427            None
428        } else {
429            self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
430        }
431    }
432
433    /// Returns all semver git tags sorted in ascending order
434    fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
435        let out = self.git.root(path).tag()?;
436        let mut tags = Vec::new();
437        // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic
438        // version
439        let common_prefixes = &["v-", "v", "release-", "release"];
440        for tag in out.lines() {
441            let mut maybe_semver = tag;
442            for &prefix in common_prefixes {
443                if let Some(rem) = tag.strip_prefix(prefix) {
444                    maybe_semver = rem;
445                    break;
446                }
447            }
448            match Version::parse(maybe_semver) {
449                Ok(v) => {
450                    // ignore if additional metadata, like rc, beta, etc...
451                    if v.build.is_empty() && v.pre.is_empty() {
452                        tags.push((tag.to_string(), v));
453                    }
454                }
455                Err(err) => {
456                    warn!(?err, ?maybe_semver, "No semver tag");
457                }
458            }
459        }
460
461        tags.sort_by(|(_, a), (_, b)| a.cmp(b));
462
463        Ok(tags)
464    }
465
466    /// Install the given dependency as git submodule in `target_dir`.
467    fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
468        let url = dep.require_url()?;
469
470        // make path relative to the git root, already checked above
471        let path = path.strip_prefix(self.git.root).unwrap();
472
473        trace!(?dep, url, ?path, "installing git submodule");
474        self.git.submodule_add(true, url, path)
475    }
476
477    fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
478        // no need to checkout if there is no tag
479        let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
480
481        let mut is_branch = false;
482        // only try to match tag if current terminal is a tty
483        if std::io::stdout().is_terminal() {
484            if tag.is_empty() {
485                tag = self.match_tag(&tag, path)?;
486            } else if let Some(branch) = self.match_branch(&tag, path)? {
487                trace!(?tag, ?branch, "selecting branch for given tag");
488                tag = branch;
489                is_branch = true;
490            }
491        }
492        let url = dep.url.as_ref().unwrap();
493
494        let res = self.git.root(path).checkout(recurse, &tag);
495        if let Err(mut e) = res {
496            // remove dependency on failed checkout
497            fs::remove_dir_all(path)?;
498            if e.to_string().contains("did not match any file(s) known to git") {
499                e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
500            }
501            return Err(e);
502        }
503
504        if is_branch { Ok(tag) } else { Ok(String::new()) }
505    }
506
507    /// disambiguate tag if it is a version tag
508    fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
509        // only try to match if it looks like a version tag
510        if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
511            return Ok(tag.into());
512        }
513
514        // generate candidate list by filtering `git tag` output, valid ones are those "starting
515        // with" the user-provided tag (ignoring the starting 'v'), for example, if the user
516        // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not
517        let trimmed_tag = tag.trim_start_matches('v').to_string();
518        let output = self.git.root(path).tag()?;
519        let mut candidates: Vec<String> = output
520            .trim()
521            .lines()
522            .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
523            .map(|x| x.to_string())
524            .rev()
525            .collect();
526
527        // no match found, fall back to the user-provided tag
528        if candidates.is_empty() {
529            return Ok(tag.into());
530        }
531
532        // have exact match
533        for candidate in &candidates {
534            if candidate == tag {
535                return Ok(tag.into());
536            }
537        }
538
539        // only one candidate, ask whether the user wants to accept or not
540        if candidates.len() == 1 {
541            let matched_tag = &candidates[0];
542            let input = prompt!(
543                "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
544            )?;
545            return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
546        }
547
548        // multiple candidates, ask the user to choose one or skip
549        candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
550        sh_println!("There are multiple matching tags:")?;
551        for (i, candidate) in candidates.iter().enumerate() {
552            sh_println!("[{i}] {candidate}")?;
553        }
554
555        let n_candidates = candidates.len();
556        loop {
557            let input: String =
558                prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
559            let s = input.trim();
560            // default selection, return first candidate
561            let n = if s.is_empty() { Ok(1) } else { s.parse() };
562            // match user input, 0 indicates skipping and use original tag
563            match n {
564                Ok(0) => return Ok(tag.into()),
565                Ok(i) if (1..=n_candidates).contains(&i) => {
566                    let c = &candidates[i];
567                    sh_println!("[{i}] {c} selected")?;
568                    return Ok(c.clone());
569                }
570                _ => continue,
571            }
572        }
573    }
574
575    fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
576        // fetch remote branches and check for tag
577        let output = self.git.root(path).cmd().args(["branch", "-r"]).get_stdout_lossy()?;
578
579        let mut candidates = output
580            .lines()
581            .map(|x| x.trim().trim_start_matches("origin/"))
582            .filter(|x| x.starts_with(tag))
583            .map(ToString::to_string)
584            .rev()
585            .collect::<Vec<_>>();
586
587        trace!(?candidates, ?tag, "found branch candidates");
588
589        // no match found, fall back to the user-provided tag
590        if candidates.is_empty() {
591            return Ok(None);
592        }
593
594        // have exact match
595        for candidate in &candidates {
596            if candidate == tag {
597                return Ok(Some(tag.to_string()));
598            }
599        }
600
601        // only one candidate, ask whether the user wants to accept or not
602        if candidates.len() == 1 {
603            let matched_tag = &candidates[0];
604            let input = prompt!(
605                "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
606            )?;
607            return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
608        }
609
610        // multiple candidates, ask the user to choose one or skip
611        candidates.insert(0, format!("{tag} (original branch)"));
612        sh_println!("There are multiple matching branches:")?;
613        for (i, candidate) in candidates.iter().enumerate() {
614            sh_println!("[{i}] {candidate}")?;
615        }
616
617        let n_candidates = candidates.len();
618        let input: String = prompt!(
619            "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
620            n_candidates - 1
621        )?;
622        let input = input.trim();
623
624        // default selection, return None
625        if input.is_empty() {
626            sh_println!("Canceled branch matching")?;
627            return Ok(None);
628        }
629
630        // match user input, 0 indicates skipping and use original tag
631        match input.parse::<usize>() {
632            Ok(0) => Ok(Some(tag.into())),
633            Ok(i) if (1..=n_candidates).contains(&i) => {
634                let c = &candidates[i];
635                sh_println!("[{i}] {c} selected")?;
636                Ok(Some(c.clone()))
637            }
638            _ => Ok(None),
639        }
640    }
641}
642
643/// Matches on the result of a prompt for yes/no.
644///
645/// Defaults to true.
646fn match_yn(input: String) -> bool {
647    let s = input.trim().to_lowercase();
648    matches!(s.as_str(), "" | "y" | "yes")
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use tempfile::tempdir;
655
656    #[test]
657    #[ignore = "slow"]
658    fn get_oz_tags() {
659        let tmp = tempdir().unwrap();
660        let git = Git::new(tmp.path());
661        let installer = Installer { git, commit: false };
662
663        git.init().unwrap();
664
665        let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
666        let libs = tmp.path().join("libs");
667        fs::create_dir(&libs).unwrap();
668        let submodule = libs.join("openzeppelin-contracts");
669        installer.git_submodule(&dep, &submodule).unwrap();
670        assert!(submodule.exists());
671
672        let tags = installer.git_semver_tags(&submodule).unwrap();
673        assert!(!tags.is_empty());
674        let v480: Version = "4.8.0".parse().unwrap();
675        assert!(tags.iter().any(|(_, v)| v == &v480));
676    }
677}