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#[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 dependencies: Vec<Dependency>,
46
47 #[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 #[arg(long)]
73 pub shallow: bool,
74
75 #[arg(long)]
77 pub no_git: bool,
78
79 #[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 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 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 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 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 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 }
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 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 if let Some(branch) = &installed_tag {
166 if git.has_branch(branch, &path)? {
168 git.cmd()
170 .args(["submodule", "set-branch", "-b", branch])
171 .arg(rel_path)
172 .exec()?;
173 }
174
175 if commit {
176 let root = Git::root_of(git.root)?;
179 git.root(&root).add(Some(".gitmodules"))?;
180 }
181 }
182
183 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 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 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 dep.tag = self.last_tag(path);
233 }
234
235 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 fs::remove_dir_all(path.join(".git"))?;
249
250 Ok(dep.tag)
251 }
252
253 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
258 self.git_submodule(dep, path)?;
260
261 let mut dep = dep.clone();
262 if dep.tag.is_none() {
263 dep.tag = self.last_tag(path);
265 }
266
267 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 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 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 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 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
329 let url = dep.require_url()?;
330
331 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 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
341
342 let mut is_branch = false;
343 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 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 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
374 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
376 return Ok(tag.into())
377 }
378
379 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 if candidates.is_empty() {
394 return Ok(tag.into())
395 }
396
397 for candidate in &candidates {
399 if candidate == tag {
400 return Ok(tag.into())
401 }
402 }
403
404 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 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 let n = if s.is_empty() { Ok(1) } else { s.parse() };
427 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 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 if candidates.is_empty() {
456 return Ok(None)
457 }
458
459 for candidate in &candidates {
461 if candidate == tag {
462 return Ok(Some(tag.to_string()))
463 }
464 }
465
466 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 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 if input.is_empty() {
491 sh_println!("Canceled branch matching")?;
492 return Ok(None)
493 }
494
495 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
508fn 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}