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