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