forge/cmd/
install.rs

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