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_uninitialized()? {
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                Err(err) => {
146                    sh_err!("Failed to check for submodules: {err}")?;
147                }
148                _ => {
149                    // no submodules, nothing to do
150                }
151            }
152        }
153
154        fs::create_dir_all(&libs)?;
155
156        let installer = Installer { git, commit };
157        for dep in dependencies {
158            let path = libs.join(dep.name());
159            let rel_path = path
160                .strip_prefix(git.root)
161                .wrap_err("Library directory is not relative to the repository root")?;
162            sh_println!(
163                "Installing {} in {} (url: {:?}, tag: {:?})",
164                dep.name,
165                path.display(),
166                dep.url,
167                dep.tag
168            )?;
169
170            // this tracks the actual installed tag
171            let installed_tag;
172            let mut dep_id = None;
173            if no_git {
174                installed_tag = installer.install_as_folder(&dep, &path)?;
175            } else {
176                if commit {
177                    git.ensure_clean()?;
178                }
179                installed_tag = installer.install_as_submodule(&dep, &path)?;
180
181                let mut new_insertion = false;
182                // Pin branch to submodule if branch is used
183                if let Some(tag_or_branch) = &installed_tag {
184                    // First, check if this tag has a branch
185                    dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
186                    if git.has_branch(tag_or_branch, &path)?
187                        && dep_id.as_ref().is_some_and(|id| id.is_branch())
188                    {
189                        // always work with relative paths when directly modifying submodules
190                        git.cmd()
191                            .args(["submodule", "set-branch", "-b", tag_or_branch])
192                            .arg(rel_path)
193                            .exec()?;
194
195                        let rev = git.get_rev(tag_or_branch, &path)?;
196
197                        dep_id = Some(DepIdentifier::Branch {
198                            name: tag_or_branch.to_string(),
199                            rev,
200                            r#override: false,
201                        });
202                    }
203
204                    trace!(?dep_id, ?tag_or_branch, "resolved dep id");
205                    if let Some(dep_id) = &dep_id {
206                        new_insertion = true;
207                        lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
208                    }
209
210                    if commit {
211                        // update .gitmodules which is at the root of the repo,
212                        // not necessarily at the root of the current Foundry project
213                        let root = Git::root_of(git.root)?;
214                        git.root(&root).add(Some(".gitmodules"))?;
215                    }
216                }
217
218                if new_insertion
219                    || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
220                    || !lockfile.exists()
221                {
222                    lockfile.write()?;
223                }
224
225                // commit the installation
226                if commit {
227                    let mut msg = String::with_capacity(128);
228                    msg.push_str("forge install: ");
229                    msg.push_str(dep.name());
230
231                    if let Some(tag) = &installed_tag {
232                        msg.push_str("\n\n");
233
234                        if let Some(dep_id) = &dep_id {
235                            msg.push_str(dep_id.to_string().as_str());
236                        } else {
237                            msg.push_str(tag);
238                        }
239                    }
240
241                    if !lockfile.is_empty() {
242                        git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
243                    }
244                    git.commit(&msg)?;
245                }
246            }
247
248            let mut msg = format!("    {} {}", "Installed".green(), dep.name);
249            if let Some(tag) = dep.tag.or(installed_tag) {
250                msg.push(' ');
251
252                if let Some(dep_id) = dep_id {
253                    msg.push_str(dep_id.to_string().as_str());
254                } else {
255                    msg.push_str(tag.as_str());
256                }
257            }
258            sh_println!("{msg}")?;
259        }
260
261        // update `libs` in config if not included yet
262        if !config.libs.iter().any(|p| p == install_lib_dir) {
263            config.libs.push(install_lib_dir.to_path_buf());
264            config.update_libs()?;
265        }
266
267        Ok(())
268    }
269}
270
271pub fn install_missing_dependencies(config: &mut Config) -> bool {
272    DependencyInstallOpts::default().install_missing_dependencies(config)
273}
274
275#[derive(Clone, Copy, Debug)]
276struct Installer<'a> {
277    git: Git<'a>,
278    commit: bool,
279}
280
281impl Installer<'_> {
282    /// Installs the dependency as an ordinary folder instead of a submodule
283    fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
284        let url = dep.require_url()?;
285        Git::clone(dep.tag.is_none(), url, Some(&path))?;
286        let mut dep = dep.clone();
287
288        if dep.tag.is_none() {
289            // try to find latest semver release tag
290            dep.tag = self.last_tag(path);
291        }
292
293        // checkout the tag if necessary
294        self.git_checkout(&dep, path, false)?;
295
296        trace!("updating dependency submodules recursively");
297        self.git.root(path).submodule_update(
298            false,
299            false,
300            false,
301            true,
302            std::iter::empty::<PathBuf>(),
303        )?;
304
305        // remove git artifacts
306        fs::remove_dir_all(path.join(".git"))?;
307
308        Ok(dep.tag)
309    }
310
311    /// Installs the dependency as new submodule.
312    ///
313    /// This will add the git submodule to the given dir, initialize it and checkout the tag if
314    /// provided or try to find the latest semver, release tag.
315    fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
316        // install the dep
317        self.git_submodule(dep, path)?;
318
319        let mut dep = dep.clone();
320        if dep.tag.is_none() {
321            // try to find latest semver release tag
322            dep.tag = self.last_tag(path);
323        }
324
325        // checkout the tag if necessary
326        self.git_checkout(&dep, path, true)?;
327
328        trace!("updating dependency submodules recursively");
329        self.git.root(path).submodule_update(
330            false,
331            false,
332            false,
333            true,
334            std::iter::empty::<PathBuf>(),
335        )?;
336
337        // sync submodules config with changes in .gitmodules, see <https://github.com/foundry-rs/foundry/issues/9611>
338        self.git.root(path).submodule_sync()?;
339
340        if self.commit {
341            self.git.add(Some(path))?;
342        }
343
344        Ok(dep.tag)
345    }
346
347    fn last_tag(self, path: &Path) -> Option<String> {
348        if self.git.shallow {
349            None
350        } else {
351            self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
352        }
353    }
354
355    /// Returns all semver git tags sorted in ascending order
356    fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
357        let out = self.git.root(path).tag()?;
358        let mut tags = Vec::new();
359        // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic
360        // version
361        let common_prefixes = &["v-", "v", "release-", "release"];
362        for tag in out.lines() {
363            let mut maybe_semver = tag;
364            for &prefix in common_prefixes {
365                if let Some(rem) = tag.strip_prefix(prefix) {
366                    maybe_semver = rem;
367                    break;
368                }
369            }
370            match Version::parse(maybe_semver) {
371                Ok(v) => {
372                    // ignore if additional metadata, like rc, beta, etc...
373                    if v.build.is_empty() && v.pre.is_empty() {
374                        tags.push((tag.to_string(), v));
375                    }
376                }
377                Err(err) => {
378                    warn!(?err, ?maybe_semver, "No semver tag");
379                }
380            }
381        }
382
383        tags.sort_by(|(_, a), (_, b)| a.cmp(b));
384
385        Ok(tags)
386    }
387
388    /// Install the given dependency as git submodule in `target_dir`.
389    fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
390        let url = dep.require_url()?;
391
392        // make path relative to the git root, already checked above
393        let path = path.strip_prefix(self.git.root).unwrap();
394
395        trace!(?dep, url, ?path, "installing git submodule");
396        self.git.submodule_add(true, url, path)
397    }
398
399    fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
400        // no need to checkout if there is no tag
401        let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
402
403        let mut is_branch = false;
404        // only try to match tag if current terminal is a tty
405        if std::io::stdout().is_terminal() {
406            if tag.is_empty() {
407                tag = self.match_tag(&tag, path)?;
408            } else if let Some(branch) = self.match_branch(&tag, path)? {
409                trace!(?tag, ?branch, "selecting branch for given tag");
410                tag = branch;
411                is_branch = true;
412            }
413        }
414        let url = dep.url.as_ref().unwrap();
415
416        let res = self.git.root(path).checkout(recurse, &tag);
417        if let Err(mut e) = res {
418            // remove dependency on failed checkout
419            fs::remove_dir_all(path)?;
420            if e.to_string().contains("did not match any file(s) known to git") {
421                e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
422            }
423            return Err(e);
424        }
425
426        if is_branch { Ok(tag) } else { Ok(String::new()) }
427    }
428
429    /// disambiguate tag if it is a version tag
430    fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
431        // only try to match if it looks like a version tag
432        if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
433            return Ok(tag.into());
434        }
435
436        // generate candidate list by filtering `git tag` output, valid ones are those "starting
437        // with" the user-provided tag (ignoring the starting 'v'), for example, if the user
438        // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not
439        let trimmed_tag = tag.trim_start_matches('v').to_string();
440        let output = self.git.root(path).tag()?;
441        let mut candidates: Vec<String> = output
442            .trim()
443            .lines()
444            .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
445            .map(|x| x.to_string())
446            .rev()
447            .collect();
448
449        // no match found, fall back to the user-provided tag
450        if candidates.is_empty() {
451            return Ok(tag.into());
452        }
453
454        // have exact match
455        for candidate in &candidates {
456            if candidate == tag {
457                return Ok(tag.into());
458            }
459        }
460
461        // only one candidate, ask whether the user wants to accept or not
462        if candidates.len() == 1 {
463            let matched_tag = &candidates[0];
464            let input = prompt!(
465                "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
466            )?;
467            return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
468        }
469
470        // multiple candidates, ask the user to choose one or skip
471        candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
472        sh_println!("There are multiple matching tags:")?;
473        for (i, candidate) in candidates.iter().enumerate() {
474            sh_println!("[{i}] {candidate}")?;
475        }
476
477        let n_candidates = candidates.len();
478        loop {
479            let input: String =
480                prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
481            let s = input.trim();
482            // default selection, return first candidate
483            let n = if s.is_empty() { Ok(1) } else { s.parse() };
484            // match user input, 0 indicates skipping and use original tag
485            match n {
486                Ok(0) => return Ok(tag.into()),
487                Ok(i) if (1..=n_candidates).contains(&i) => {
488                    let c = &candidates[i];
489                    sh_println!("[{i}] {c} selected")?;
490                    return Ok(c.clone());
491                }
492                _ => continue,
493            }
494        }
495    }
496
497    fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
498        // fetch remote branches and check for tag
499        let output = self.git.root(path).cmd().args(["branch", "-r"]).get_stdout_lossy()?;
500
501        let mut candidates = output
502            .lines()
503            .map(|x| x.trim().trim_start_matches("origin/"))
504            .filter(|x| x.starts_with(tag))
505            .map(ToString::to_string)
506            .rev()
507            .collect::<Vec<_>>();
508
509        trace!(?candidates, ?tag, "found branch candidates");
510
511        // no match found, fall back to the user-provided tag
512        if candidates.is_empty() {
513            return Ok(None);
514        }
515
516        // have exact match
517        for candidate in &candidates {
518            if candidate == tag {
519                return Ok(Some(tag.to_string()));
520            }
521        }
522
523        // only one candidate, ask whether the user wants to accept or not
524        if candidates.len() == 1 {
525            let matched_tag = &candidates[0];
526            let input = prompt!(
527                "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
528            )?;
529            return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
530        }
531
532        // multiple candidates, ask the user to choose one or skip
533        candidates.insert(0, format!("{tag} (original branch)"));
534        sh_println!("There are multiple matching branches:")?;
535        for (i, candidate) in candidates.iter().enumerate() {
536            sh_println!("[{i}] {candidate}")?;
537        }
538
539        let n_candidates = candidates.len();
540        let input: String = prompt!(
541            "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
542            n_candidates - 1
543        )?;
544        let input = input.trim();
545
546        // default selection, return None
547        if input.is_empty() {
548            sh_println!("Canceled branch matching")?;
549            return Ok(None);
550        }
551
552        // match user input, 0 indicates skipping and use original tag
553        match input.parse::<usize>() {
554            Ok(0) => Ok(Some(tag.into())),
555            Ok(i) if (1..=n_candidates).contains(&i) => {
556                let c = &candidates[i];
557                sh_println!("[{i}] {c} selected")?;
558                Ok(Some(c.clone()))
559            }
560            _ => Ok(None),
561        }
562    }
563}
564
565/// Matches on the result of a prompt for yes/no.
566///
567/// Defaults to true.
568fn match_yn(input: String) -> bool {
569    let s = input.trim().to_lowercase();
570    matches!(s.as_str(), "" | "y" | "yes")
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use tempfile::tempdir;
577
578    #[test]
579    #[ignore = "slow"]
580    fn get_oz_tags() {
581        let tmp = tempdir().unwrap();
582        let git = Git::new(tmp.path());
583        let installer = Installer { git, commit: false };
584
585        git.init().unwrap();
586
587        let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
588        let libs = tmp.path().join("libs");
589        fs::create_dir(&libs).unwrap();
590        let submodule = libs.join("openzeppelin-contracts");
591        installer.git_submodule(&dep, &submodule).unwrap();
592        assert!(submodule.exists());
593
594        let tags = installer.git_semver_tags(&submodule).unwrap();
595        assert!(!tags.is_empty());
596        let v480: Version = "4.8.0".parse().unwrap();
597        assert!(tags.iter().any(|(_, v)| v == &v480));
598    }
599}