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        // sync submodules config with changes in .gitmodules, see <https://github.com/foundry-rs/foundry/issues/9611>
280        self.git.root(path).submodule_sync()?;
281
282        if self.commit {
283            self.git.add(Some(path))?;
284        }
285
286        Ok(dep.tag)
287    }
288
289    fn last_tag(self, path: &Path) -> Option<String> {
290        if self.git.shallow {
291            None
292        } else {
293            self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
294        }
295    }
296
297    /// Returns all semver git tags sorted in ascending order
298    fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
299        let out = self.git.root(path).tag()?;
300        let mut tags = Vec::new();
301        // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic
302        // version
303        let common_prefixes = &["v-", "v", "release-", "release"];
304        for tag in out.lines() {
305            let mut maybe_semver = tag;
306            for &prefix in common_prefixes {
307                if let Some(rem) = tag.strip_prefix(prefix) {
308                    maybe_semver = rem;
309                    break
310                }
311            }
312            match Version::parse(maybe_semver) {
313                Ok(v) => {
314                    // ignore if additional metadata, like rc, beta, etc...
315                    if v.build.is_empty() && v.pre.is_empty() {
316                        tags.push((tag.to_string(), v));
317                    }
318                }
319                Err(err) => {
320                    warn!(?err, ?maybe_semver, "No semver tag");
321                }
322            }
323        }
324
325        tags.sort_by(|(_, a), (_, b)| a.cmp(b));
326
327        Ok(tags)
328    }
329
330    /// Install the given dependency as git submodule in `target_dir`.
331    fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
332        let url = dep.require_url()?;
333
334        // make path relative to the git root, already checked above
335        let path = path.strip_prefix(self.git.root).unwrap();
336
337        trace!(?dep, url, ?path, "installing git submodule");
338        self.git.submodule_add(true, url, path)
339    }
340
341    fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
342        // no need to checkout if there is no tag
343        let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
344
345        let mut is_branch = false;
346        // only try to match tag if current terminal is a tty
347        if std::io::stdout().is_terminal() {
348            if tag.is_empty() {
349                tag = self.match_tag(&tag, path)?;
350            } else if let Some(branch) = self.match_branch(&tag, path)? {
351                trace!(?tag, ?branch, "selecting branch for given tag");
352                tag = branch;
353                is_branch = true;
354            }
355        }
356        let url = dep.url.as_ref().unwrap();
357
358        let res = self.git.root(path).checkout(recurse, &tag);
359        if let Err(mut e) = res {
360            // remove dependency on failed checkout
361            fs::remove_dir_all(path)?;
362            if e.to_string().contains("did not match any file(s) known to git") {
363                e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
364            }
365            return Err(e)
366        }
367
368        if is_branch {
369            Ok(tag)
370        } else {
371            Ok(String::new())
372        }
373    }
374
375    /// disambiguate tag if it is a version tag
376    fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
377        // only try to match if it looks like a version tag
378        if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
379            return Ok(tag.into())
380        }
381
382        // generate candidate list by filtering `git tag` output, valid ones are those "starting
383        // with" the user-provided tag (ignoring the starting 'v'), for example, if the user
384        // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not
385        let trimmed_tag = tag.trim_start_matches('v').to_string();
386        let output = self.git.root(path).tag()?;
387        let mut candidates: Vec<String> = output
388            .trim()
389            .lines()
390            .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
391            .map(|x| x.to_string())
392            .rev()
393            .collect();
394
395        // no match found, fall back to the user-provided tag
396        if candidates.is_empty() {
397            return Ok(tag.into())
398        }
399
400        // have exact match
401        for candidate in &candidates {
402            if candidate == tag {
403                return Ok(tag.into())
404            }
405        }
406
407        // only one candidate, ask whether the user wants to accept or not
408        if candidates.len() == 1 {
409            let matched_tag = &candidates[0];
410            let input = prompt!(
411                "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
412            )?;
413            return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) }
414        }
415
416        // multiple candidates, ask the user to choose one or skip
417        candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
418        sh_println!("There are multiple matching tags:")?;
419        for (i, candidate) in candidates.iter().enumerate() {
420            sh_println!("[{i}] {candidate}")?;
421        }
422
423        let n_candidates = candidates.len();
424        loop {
425            let input: String =
426                prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
427            let s = input.trim();
428            // default selection, return first candidate
429            let n = if s.is_empty() { Ok(1) } else { s.parse() };
430            // match user input, 0 indicates skipping and use original tag
431            match n {
432                Ok(0) => return Ok(tag.into()),
433                Ok(i) if (1..=n_candidates).contains(&i) => {
434                    let c = &candidates[i];
435                    sh_println!("[{i}] {c} selected")?;
436                    return Ok(c.clone())
437                }
438                _ => continue,
439            }
440        }
441    }
442
443    fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
444        // fetch remote branches and check for tag
445        let output = self.git.root(path).cmd().args(["branch", "-r"]).get_stdout_lossy()?;
446
447        let mut candidates = output
448            .lines()
449            .map(|x| x.trim().trim_start_matches("origin/"))
450            .filter(|x| x.starts_with(tag))
451            .map(ToString::to_string)
452            .rev()
453            .collect::<Vec<_>>();
454
455        trace!(?candidates, ?tag, "found branch candidates");
456
457        // no match found, fall back to the user-provided tag
458        if candidates.is_empty() {
459            return Ok(None)
460        }
461
462        // have exact match
463        for candidate in &candidates {
464            if candidate == tag {
465                return Ok(Some(tag.to_string()))
466            }
467        }
468
469        // only one candidate, ask whether the user wants to accept or not
470        if candidates.len() == 1 {
471            let matched_tag = &candidates[0];
472            let input = prompt!(
473                "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
474            )?;
475            return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) }
476        }
477
478        // multiple candidates, ask the user to choose one or skip
479        candidates.insert(0, format!("{tag} (original branch)"));
480        sh_println!("There are multiple matching branches:")?;
481        for (i, candidate) in candidates.iter().enumerate() {
482            sh_println!("[{i}] {candidate}")?;
483        }
484
485        let n_candidates = candidates.len();
486        let input: String = prompt!(
487            "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
488            n_candidates - 1
489        )?;
490        let input = input.trim();
491
492        // default selection, return None
493        if input.is_empty() {
494            sh_println!("Canceled branch matching")?;
495            return Ok(None)
496        }
497
498        // match user input, 0 indicates skipping and use original tag
499        match input.parse::<usize>() {
500            Ok(0) => Ok(Some(tag.into())),
501            Ok(i) if (1..=n_candidates).contains(&i) => {
502                let c = &candidates[i];
503                sh_println!("[{i}] {c} selected")?;
504                Ok(Some(c.clone()))
505            }
506            _ => Ok(None),
507        }
508    }
509}
510
511/// Matches on the result of a prompt for yes/no.
512///
513/// Defaults to true.
514fn match_yn(input: String) -> bool {
515    let s = input.trim().to_lowercase();
516    matches!(s.as_str(), "" | "y" | "yes")
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use tempfile::tempdir;
523
524    #[test]
525    #[ignore = "slow"]
526    fn get_oz_tags() {
527        let tmp = tempdir().unwrap();
528        let git = Git::new(tmp.path());
529        let installer = Installer { git, commit: false };
530
531        git.init().unwrap();
532
533        let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
534        let libs = tmp.path().join("libs");
535        fs::create_dir(&libs).unwrap();
536        let submodule = libs.join("openzeppelin-contracts");
537        installer.git_submodule(&dep, &submodule).unwrap();
538        assert!(submodule.exists());
539
540        let tags = installer.git_semver_tags(&submodule).unwrap();
541        assert!(!tags.is_empty());
542        let v480: Version = "4.8.0".parse().unwrap();
543        assert!(tags.iter().any(|(_, v)| v == &v480));
544    }
545}