forge/cmd/
install.rs

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