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 soldeer_commands::{Command, Verbosity, commands::install::Install};
13use std::{
14 io::IsTerminal,
15 path::{Path, PathBuf},
16 str,
17 sync::LazyLock,
18};
19use yansi::Paint;
20
21static DEPENDENCY_VERSION_TAG_REGEX: LazyLock<Regex> =
22 LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap());
23
24#[derive(Clone, Debug, Parser)]
26#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]...
27 forge install [OPTIONS] <github username>/<github project>@<tag>...
28 forge install [OPTIONS] <alias>=<github username>/<github project>@<tag>...
29 forge install [OPTIONS] <https://<github token>@git url>...)]
30 forge install [OPTIONS] <https:// git url>...")]
31pub struct InstallArgs {
32 dependencies: Vec<Dependency>,
48
49 #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
54 pub root: Option<PathBuf>,
55
56 #[command(flatten)]
57 opts: DependencyInstallOpts,
58}
59
60impl_figment_convert_basic!(InstallArgs);
61
62impl InstallArgs {
63 pub async fn run(self) -> Result<()> {
64 let mut config = self.load_config()?;
65 self.opts.install(&mut config, self.dependencies).await
66 }
67}
68
69#[derive(Clone, Copy, Debug, Default, Parser)]
70pub struct DependencyInstallOpts {
71 #[arg(long)]
75 pub shallow: bool,
76
77 #[arg(long)]
79 pub no_git: bool,
80
81 #[arg(long)]
83 pub commit: bool,
84}
85
86impl DependencyInstallOpts {
87 pub fn git(self, config: &Config) -> Git<'_> {
88 Git::from_config(config).shallow(self.shallow)
89 }
90
91 pub async fn install_missing_dependencies(self, config: &mut Config) -> bool {
97 let lib = config.install_lib_dir();
98 if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
99 let _ = sh_println!("Missing dependencies found. Installing now...\n");
101
102 if self.install(config, Vec::new()).await.is_err() {
103 let _ =
104 sh_warn!("Your project has missing dependencies that could not be installed.");
105 }
106 true
107 } else {
108 false
109 }
110 }
111
112 pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
114 let Self { no_git, commit, .. } = self;
115
116 let git = self.git(config);
117
118 let install_lib_dir = config.install_lib_dir();
119 let libs = git.root.join(install_lib_dir);
120
121 let mut lockfile = Lockfile::new(&config.root);
122 if !no_git {
123 lockfile = lockfile.with_git(&git);
124
125 if git.submodules_uninitialized()? {
129 trace!(lib = %libs.display(), "submodules uninitialized");
130 git.submodule_update(false, false, false, true, Some(&libs))?;
131 }
132 }
133
134 let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
135
136 if dependencies.is_empty() && !no_git {
137 let root = Git::root_of(git.root)?;
139 match git.has_submodules(Some(&root)) {
140 Ok(true) => {
141 sh_println!("Updating dependencies in {}", libs.display())?;
142
143 git.submodule_update(false, false, false, true, Some(&libs))?;
145 lockfile.write()?;
146 }
147 Err(err) => {
148 sh_err!("Failed to check for submodules: {err}")?;
149 }
150 _ => {
151 }
153 }
154 }
155
156 fs::create_dir_all(&libs)?;
157
158 let installer = Installer { git, commit };
159 for dep in dependencies {
160 let path = libs.join(dep.name());
161 let rel_path = path
162 .strip_prefix(git.root)
163 .wrap_err("Library directory is not relative to the repository root")?;
164 sh_println!(
165 "Installing {} in {} (url: {}, tag: {})",
166 dep.name,
167 path.display(),
168 dep.url.as_deref().unwrap_or("None"),
169 dep.tag.as_deref().unwrap_or("None")
170 )?;
171
172 let installed_tag;
174 let mut dep_id = None;
175 if no_git {
176 installed_tag = installer.install_as_folder(&dep, &path)?;
177 } else {
178 if commit {
179 git.ensure_clean()?;
180 }
181 installed_tag = installer.install_as_submodule(&dep, &path)?;
182
183 let mut new_insertion = false;
184 if let Some(tag_or_branch) = &installed_tag {
186 dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
188 if git.has_branch(tag_or_branch, &path)?
189 && dep_id.as_ref().is_some_and(|id| id.is_branch())
190 {
191 git.cmd()
193 .args(["submodule", "set-branch", "-b", tag_or_branch])
194 .arg(rel_path)
195 .exec()?;
196
197 let rev = git.get_rev(tag_or_branch, &path)?;
198
199 dep_id = Some(DepIdentifier::Branch {
200 name: tag_or_branch.to_string(),
201 rev,
202 r#override: false,
203 });
204 }
205
206 trace!(?dep_id, ?tag_or_branch, "resolved dep id");
207 if let Some(dep_id) = &dep_id {
208 new_insertion = true;
209 lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
210 }
211
212 if commit {
213 let root = Git::root_of(git.root)?;
216 git.root(&root).add(Some(".gitmodules"))?;
217 }
218 }
219
220 if new_insertion
221 || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
222 || !lockfile.exists()
223 {
224 lockfile.write()?;
225 }
226
227 if commit {
229 let mut msg = String::with_capacity(128);
230 msg.push_str("forge install: ");
231 msg.push_str(dep.name());
232
233 if let Some(tag) = &installed_tag {
234 msg.push_str("\n\n");
235
236 if let Some(dep_id) = &dep_id {
237 msg.push_str(&dep_id.to_string());
238 } else {
239 msg.push_str(tag);
240 }
241 }
242
243 if !lockfile.is_empty() {
244 git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
245 }
246 git.commit(&msg)?;
247 }
248 }
249
250 let mut msg = format!(" {} {}", "Installed".green(), dep.name);
251 if let Some(tag) = dep.tag.or(installed_tag) {
252 msg.push(' ');
253
254 if let Some(dep_id) = dep_id {
255 msg.push_str(&dep_id.to_string());
256 } else {
257 msg.push_str(tag.as_str());
258 }
259 }
260 sh_println!("{msg}")?;
261
262 if let Err(e) = install_soldeer_deps_if_needed(&path).await {
264 sh_warn!("Failed to install soldeer dependencies for {}: {e}", dep.name)?;
265 }
266 }
267
268 if !config.libs.iter().any(|p| p == install_lib_dir) {
270 config.libs.push(install_lib_dir.to_path_buf());
271 config.update_libs()?;
272 }
273
274 Ok(())
275 }
276}
277
278pub async fn install_missing_dependencies(config: &mut Config) -> bool {
279 DependencyInstallOpts::default().install_missing_dependencies(config).await
280}
281
282async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
284 let soldeer_lock = dep_path.join("soldeer.lock");
285
286 if soldeer_lock.exists() {
287 sh_println!(" Found soldeer.lock, installing soldeer dependencies...")?;
288
289 let original_dir = std::env::current_dir()?;
291 std::env::set_current_dir(dep_path)?;
292
293 let result = soldeer_commands::run(
294 Command::Install(Install::default()),
295 Verbosity::new(
296 foundry_common::shell::verbosity(),
297 if foundry_common::shell::is_quiet() { 1 } else { 0 },
298 ),
299 )
300 .await;
301
302 std::env::set_current_dir(original_dir)?;
304
305 result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
306 sh_println!(" Soldeer dependencies installed successfully")?;
307 }
308
309 Ok(())
310}
311
312#[derive(Clone, Copy, Debug)]
313struct Installer<'a> {
314 git: Git<'a>,
315 commit: bool,
316}
317
318impl Installer<'_> {
319 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
321 let url = dep.require_url()?;
322 Git::clone(dep.tag.is_none(), url, Some(&path))?;
323 let mut dep = dep.clone();
324
325 if dep.tag.is_none() {
326 dep.tag = self.last_tag(path);
328 }
329
330 self.git_checkout(&dep, path, true)?;
334
335 trace!("updating dependency submodules recursively");
336 self.git.root(path).submodule_update(
337 false,
338 false,
339 false,
340 true,
341 std::iter::empty::<PathBuf>(),
342 )?;
343
344 Self::remove_nested_git_dirs(path)?;
346
347 fs::remove_dir_all(path.join(".git"))?;
349
350 Ok(dep.tag)
351 }
352
353 fn remove_nested_git_dirs(root: &Path) -> Result<()> {
358 Self::remove_nested_git_dirs_inner(root, root)
359 }
360
361 fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
362 let entries = match std::fs::read_dir(dir) {
363 Ok(entries) => entries,
364 Err(_) => return Ok(()),
365 };
366 for entry in entries {
367 let entry = entry?;
368 let ft = entry.file_type()?;
369
370 if ft.is_symlink() {
372 continue;
373 }
374
375 let path = entry.path();
376 if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
377 if ft.is_dir() {
378 fs::remove_dir_all(&path)?;
379 } else {
380 fs::remove_file(&path)?;
381 }
382 } else if ft.is_dir() {
383 Self::remove_nested_git_dirs_inner(root, &path)?;
384 }
385 }
386 Ok(())
387 }
388
389 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
394 self.git_submodule(dep, path)?;
396
397 let mut dep = dep.clone();
398 if dep.tag.is_none() {
399 dep.tag = self.last_tag(path);
401 }
402
403 self.git_checkout(&dep, path, true)?;
405
406 trace!("updating dependency submodules recursively");
407 self.git.root(path).submodule_update(
408 false,
409 false,
410 false,
411 true,
412 std::iter::empty::<PathBuf>(),
413 )?;
414
415 self.git.root(path).submodule_sync()?;
417
418 if self.commit {
419 self.git.add(Some(path))?;
420 }
421
422 Ok(dep.tag)
423 }
424
425 fn last_tag(self, path: &Path) -> Option<String> {
426 if self.git.shallow {
427 None
428 } else {
429 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
430 }
431 }
432
433 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
435 let out = self.git.root(path).tag()?;
436 let mut tags = Vec::new();
437 let common_prefixes = &["v-", "v", "release-", "release"];
440 for tag in out.lines() {
441 let mut maybe_semver = tag;
442 for &prefix in common_prefixes {
443 if let Some(rem) = tag.strip_prefix(prefix) {
444 maybe_semver = rem;
445 break;
446 }
447 }
448 match Version::parse(maybe_semver) {
449 Ok(v) => {
450 if v.build.is_empty() && v.pre.is_empty() {
452 tags.push((tag.to_string(), v));
453 }
454 }
455 Err(err) => {
456 warn!(?err, ?maybe_semver, "No semver tag");
457 }
458 }
459 }
460
461 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
462
463 Ok(tags)
464 }
465
466 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
468 let url = dep.require_url()?;
469
470 let path = path.strip_prefix(self.git.root).unwrap();
472
473 trace!(?dep, url, ?path, "installing git submodule");
474 self.git.submodule_add(true, url, path)
475 }
476
477 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
478 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
480
481 let mut is_branch = false;
482 if std::io::stdout().is_terminal() {
484 if tag.is_empty() {
485 tag = self.match_tag(&tag, path)?;
486 } else if let Some(branch) = self.match_branch(&tag, path)? {
487 trace!(?tag, ?branch, "selecting branch for given tag");
488 tag = branch;
489 is_branch = true;
490 }
491 }
492 let url = dep.url.as_ref().unwrap();
493
494 let res = self.git.root(path).checkout(recurse, &tag);
495 if let Err(mut e) = res {
496 fs::remove_dir_all(path)?;
498 if e.to_string().contains("did not match any file(s) known to git") {
499 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
500 }
501 return Err(e);
502 }
503
504 if is_branch { Ok(tag) } else { Ok(String::new()) }
505 }
506
507 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
509 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
511 return Ok(tag.into());
512 }
513
514 let trimmed_tag = tag.trim_start_matches('v').to_string();
518 let output = self.git.root(path).tag()?;
519 let mut candidates: Vec<String> = output
520 .trim()
521 .lines()
522 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
523 .map(|x| x.to_string())
524 .rev()
525 .collect();
526
527 if candidates.is_empty() {
529 return Ok(tag.into());
530 }
531
532 for candidate in &candidates {
534 if candidate == tag {
535 return Ok(tag.into());
536 }
537 }
538
539 if candidates.len() == 1 {
541 let matched_tag = &candidates[0];
542 let input = prompt!(
543 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
544 )?;
545 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
546 }
547
548 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
550 sh_println!("There are multiple matching tags:")?;
551 for (i, candidate) in candidates.iter().enumerate() {
552 sh_println!("[{i}] {candidate}")?;
553 }
554
555 let n_candidates = candidates.len();
556 loop {
557 let input: String =
558 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
559 let s = input.trim();
560 let n = if s.is_empty() { Ok(1) } else { s.parse() };
562 match n {
564 Ok(0) => return Ok(tag.into()),
565 Ok(i) if (1..=n_candidates).contains(&i) => {
566 let c = &candidates[i];
567 sh_println!("[{i}] {c} selected")?;
568 return Ok(c.clone());
569 }
570 _ => continue,
571 }
572 }
573 }
574
575 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
576 let output = self.git.root(path).cmd().args(["branch", "-r"]).get_stdout_lossy()?;
578
579 let mut candidates = output
580 .lines()
581 .map(|x| x.trim().trim_start_matches("origin/"))
582 .filter(|x| x.starts_with(tag))
583 .map(ToString::to_string)
584 .rev()
585 .collect::<Vec<_>>();
586
587 trace!(?candidates, ?tag, "found branch candidates");
588
589 if candidates.is_empty() {
591 return Ok(None);
592 }
593
594 for candidate in &candidates {
596 if candidate == tag {
597 return Ok(Some(tag.to_string()));
598 }
599 }
600
601 if candidates.len() == 1 {
603 let matched_tag = &candidates[0];
604 let input = prompt!(
605 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
606 )?;
607 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
608 }
609
610 candidates.insert(0, format!("{tag} (original branch)"));
612 sh_println!("There are multiple matching branches:")?;
613 for (i, candidate) in candidates.iter().enumerate() {
614 sh_println!("[{i}] {candidate}")?;
615 }
616
617 let n_candidates = candidates.len();
618 let input: String = prompt!(
619 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
620 n_candidates - 1
621 )?;
622 let input = input.trim();
623
624 if input.is_empty() {
626 sh_println!("Canceled branch matching")?;
627 return Ok(None);
628 }
629
630 match input.parse::<usize>() {
632 Ok(0) => Ok(Some(tag.into())),
633 Ok(i) if (1..=n_candidates).contains(&i) => {
634 let c = &candidates[i];
635 sh_println!("[{i}] {c} selected")?;
636 Ok(Some(c.clone()))
637 }
638 _ => Ok(None),
639 }
640 }
641}
642
643fn match_yn(input: String) -> bool {
647 let s = input.trim().to_lowercase();
648 matches!(s.as_str(), "" | "y" | "yes")
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use tempfile::tempdir;
655
656 #[test]
657 #[ignore = "slow"]
658 fn get_oz_tags() {
659 let tmp = tempdir().unwrap();
660 let git = Git::new(tmp.path());
661 let installer = Installer { git, commit: false };
662
663 git.init().unwrap();
664
665 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
666 let libs = tmp.path().join("libs");
667 fs::create_dir(&libs).unwrap();
668 let submodule = libs.join("openzeppelin-contracts");
669 installer.git_submodule(&dep, &submodule).unwrap();
670 assert!(submodule.exists());
671
672 let tags = installer.git_semver_tags(&submodule).unwrap();
673 assert!(!tags.is_empty());
674 let v480: Version = "4.8.0".parse().unwrap();
675 assert!(tags.iter().any(|(_, v)| v == &v480));
676 }
677}