1use crate::{DepIdentifier, FOUNDRY_LOCK, Lockfile};
2use clap::{Parser, ValueHint};
3use eyre::{Context, Result};
4use foundry_cli::{
5 opts::Dependency,
6 utils::{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 #[arg(long, hide = true)]
61 pub no_commit: bool,
62
63 #[command(flatten)]
64 opts: DependencyInstallOpts,
65}
66
67impl_figment_convert_basic!(InstallArgs);
68
69impl InstallArgs {
70 pub async fn run(self) -> Result<()> {
71 let mut config = self.load_config()?;
72 self.opts.install(&mut config, self.dependencies).await
73 }
74}
75
76#[derive(Clone, Copy, Debug, Default, Parser)]
77pub struct DependencyInstallOpts {
78 #[arg(long)]
82 pub shallow: bool,
83
84 #[arg(long)]
86 pub no_git: bool,
87
88 #[arg(long)]
90 pub commit: bool,
91}
92
93impl DependencyInstallOpts {
94 pub fn git(self, config: &Config) -> Git<'_> {
95 Git::from_config(config).shallow(self.shallow)
96 }
97
98 pub async fn install_missing_dependencies(self, config: &mut Config) -> bool {
104 let lib = config.install_lib_dir();
105 if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
106 let _ = sh_status!("Missing dependencies found. Installing now...");
107
108 if self.install(config, Vec::new()).await.is_err() {
109 let _ =
110 sh_warn!("Your project has missing dependencies that could not be installed.");
111 }
112 true
113 } else {
114 false
115 }
116 }
117
118 pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
120 let Self { no_git, commit, .. } = self;
121
122 let git = self.git(config);
123
124 let install_lib_dir = config.install_lib_dir();
125 let libs = git.root.join(install_lib_dir);
126
127 let mut lockfile = Lockfile::new(&config.root);
128 if !no_git {
129 lockfile = lockfile.with_git(&git);
130
131 if git.submodules_uninitialized()? {
135 trace!(lib = %libs.display(), "submodules uninitialized");
136 git.submodule_update(false, false, false, true, Some(&libs))?;
137 }
138 }
139
140 let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
141
142 if dependencies.is_empty() && !no_git {
143 let root = Git::root_of(git.root)?;
145 match git.has_submodules(Some(&root)) {
146 Ok(true) => {
147 sh_status!("Updating dependencies in {}", libs.display())?;
148
149 git.submodule_update(false, false, false, true, Some(&libs))?;
151
152 if let Some(out_of_sync) = &out_of_sync_deps {
154 for (rel_path, dep_id) in out_of_sync {
155 git.checkout_at(dep_id.checkout_id(), &git.root.join(rel_path))?;
156 }
157 }
158
159 lockfile.write()?;
160 }
161 Err(err) => {
162 sh_err!("Failed to check for submodules: {err}")?;
163 }
164 _ => {
165 }
167 }
168 }
169
170 fs::create_dir_all(&libs)?;
171
172 let installer = Installer { git, commit };
173 for dep in dependencies {
174 if dep
175 .name()
176 .split(['/', '\\'])
177 .any(|component| component.is_empty() || matches!(component, "." | ".."))
178 {
179 eyre::bail!("invalid dependency name: {}", dep.name());
180 }
181 let path = libs.join(dep.name());
182 let rel_path = path
183 .strip_prefix(git.root)
184 .wrap_err("Library directory is not relative to the repository root")?;
185 sh_status!(
186 "Installing {} in {} (url: {}, tag: {})",
187 dep.name,
188 path.display(),
189 dep.url.as_deref().unwrap_or("None"),
190 dep.tag.as_deref().unwrap_or("None")
191 )?;
192
193 let installed_tag;
195 let mut dep_id = None;
196 if no_git {
197 installed_tag = installer.install_as_folder(&dep, &path)?;
198 } else {
199 if commit {
200 git.ensure_clean()?;
201 }
202 installed_tag = installer.install_as_submodule(&dep, &path)?;
203
204 let mut new_insertion = false;
205 if let Some(tag_or_branch) = &installed_tag {
207 dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
209 if git.has_branch(tag_or_branch, &path)?
210 && dep_id.as_ref().is_some_and(|id| id.is_branch())
211 {
212 git.set_submodule_branch(rel_path, tag_or_branch)?;
214 let root = Git::root_of(git.root)?;
215 git.root(&root).add_literal(Path::new(".gitmodules"))?;
216
217 let rev = git.get_rev(tag_or_branch, &path)?;
218
219 dep_id = Some(DepIdentifier::Branch {
220 name: tag_or_branch.clone(),
221 rev,
222 r#override: false,
223 });
224 }
225
226 trace!(?dep_id, ?tag_or_branch, "resolved dep id");
227 if let Some(dep_id) = &dep_id {
228 new_insertion = true;
229 lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
230 }
231
232 if commit {
233 let root = Git::root_of(git.root)?;
236 git.root(&root).add(Some(".gitmodules"))?;
237 }
238 }
239
240 if new_insertion
241 || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
242 || !lockfile.exists()
243 {
244 lockfile.write()?;
245 }
246
247 if commit {
249 let mut msg = String::with_capacity(128);
250 msg.push_str("forge install: ");
251 msg.push_str(dep.name());
252
253 if let Some(tag) = &installed_tag {
254 msg.push_str("\n\n");
255
256 if let Some(dep_id) = &dep_id {
257 msg.push_str(&dep_id.to_string());
258 } else {
259 msg.push_str(tag);
260 }
261 }
262
263 if !lockfile.is_empty() {
264 git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
265 }
266 git.commit(&msg)?;
267 }
268 }
269
270 let mut msg = format!(" {} {}", "Installed".green(), dep.name);
271 if let Some(tag) = dep.tag.or(installed_tag) {
272 msg.push(' ');
273
274 if let Some(dep_id) = dep_id {
275 msg.push_str(&dep_id.to_string());
276 } else {
277 msg.push_str(tag.as_str());
278 }
279 }
280 sh_status!("{msg}")?;
281
282 if let Err(e) = install_soldeer_deps_if_needed(&path).await {
284 sh_warn!("Failed to install soldeer dependencies for {}: {e}", dep.name)?;
285 }
286 }
287
288 if !config.libs.iter().any(|p| p == install_lib_dir) {
290 config.libs.push(install_lib_dir.to_path_buf());
291 config.update_libs()?;
292 }
293
294 Ok(())
295 }
296}
297
298pub async fn install_missing_dependencies(config: &mut Config) -> bool {
299 DependencyInstallOpts::default().install_missing_dependencies(config).await
300}
301
302async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
304 let soldeer_lock = dep_path.join("soldeer.lock");
305
306 if soldeer_lock.exists() {
307 sh_status!(" Found soldeer.lock, installing soldeer dependencies...")?;
308
309 let original_dir = std::env::current_dir()?;
311 std::env::set_current_dir(dep_path)?;
312
313 let result = soldeer_commands::run(
314 Command::Install(Install::default()),
315 Verbosity::new(
316 foundry_common::shell::verbosity(),
317 if foundry_common::shell::is_quiet() { 1 } else { 0 },
318 ),
319 )
320 .await;
321
322 std::env::set_current_dir(original_dir)?;
324
325 result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
326 sh_status!(" Soldeer dependencies installed successfully")?;
327 }
328
329 Ok(())
330}
331
332#[derive(Clone, Copy, Debug)]
333struct Installer<'a> {
334 git: Git<'a>,
335 commit: bool,
336}
337
338struct NewSubmoduleGuard {
339 root: PathBuf,
340 relative_path: PathBuf,
341 path: PathBuf,
342 module_dir: PathBuf,
343 gitmodules_contents: Option<Vec<u8>>,
344 armed: bool,
345}
346
347impl NewSubmoduleGuard {
348 const fn disarm(&mut self) {
349 self.armed = false;
350 }
351
352 fn rollback(&self) {
353 let git = Git::new(&self.root);
354 if let Err(err) = git.remove_index_path(&self.relative_path) {
355 warn!(%err, "failed to remove submodule after installation failure");
356 }
357 if self.path.exists()
358 && let Err(err) = fs::remove_dir_all(&self.path)
359 {
360 warn!(%err, "failed to remove dependency after installation failure");
361 }
362 if self.module_dir.exists()
363 && let Err(err) = fs::remove_dir_all(&self.module_dir)
364 {
365 warn!(%err, "failed to remove submodule Git directory after installation failure");
366 }
367 if let Err(err) = git.remove_submodule_config(&self.relative_path) {
368 warn!(%err, "failed to remove submodule config after installation failure");
369 }
370 restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref());
371 if let Err(err) = git.add_literal(Path::new(".gitmodules")) {
372 warn!(%err, "failed to restore staged .gitmodules after installation failure");
373 }
374 }
375}
376
377impl Drop for NewSubmoduleGuard {
378 fn drop(&mut self) {
379 if self.armed {
380 self.rollback();
381 }
382 }
383}
384
385fn restore_file(path: &Path, contents: Option<&[u8]>) {
386 let result = match contents {
387 Some(contents) => fs::write(path, contents),
388 None if path.exists() => fs::remove_file(path),
389 None => Ok(()),
390 };
391 if let Err(err) = result {
392 warn!(%err, path = %path.display(), "failed to restore file after installation failure");
393 }
394}
395
396impl Installer<'_> {
397 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
399 let url = dep.require_url()?;
400 Git::clone(dep.tag.is_none(), url, Some(&path))?;
401 let mut dep = dep.clone();
402
403 if dep.tag.is_none() {
404 dep.tag = self.last_tag(path);
406 }
407
408 self.git_checkout(&dep, path, true)?;
412
413 trace!("updating dependency submodules recursively");
414 self.git.root(path).submodule_update(
415 false,
416 false,
417 false,
418 true,
419 std::iter::empty::<PathBuf>(),
420 )?;
421
422 Self::remove_nested_git_dirs(path)?;
424
425 fs::remove_dir_all(path.join(".git"))?;
427
428 Ok(dep.tag)
429 }
430
431 fn remove_nested_git_dirs(root: &Path) -> Result<()> {
436 Self::remove_nested_git_dirs_inner(root, root)
437 }
438
439 fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
440 let entries = match std::fs::read_dir(dir) {
441 Ok(entries) => entries,
442 Err(_) => return Ok(()),
443 };
444 for entry in entries {
445 let entry = entry?;
446 let ft = entry.file_type()?;
447
448 if ft.is_symlink() {
450 continue;
451 }
452
453 let path = entry.path();
454 if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
455 if ft.is_dir() {
456 fs::remove_dir_all(&path)?;
457 } else {
458 fs::remove_file(&path)?;
459 }
460 } else if ft.is_dir() {
461 Self::remove_nested_git_dirs_inner(root, &path)?;
462 }
463 }
464 Ok(())
465 }
466
467 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
472 let root = Git::root_of(self.git.root)?;
473 let relative_path = path.strip_prefix(&root)?;
474 let git = self.git.root(&root);
475 let gitmodules = root.join(".gitmodules");
476 let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?;
477 let has_mapping = git.has_submodule_mapping(relative_path)?;
478 let is_gitlink = git.is_gitlink(relative_path)?;
479 if has_mapping != is_gitlink {
480 eyre::bail!(
481 "cannot safely install dependency at {} because .gitmodules already contains a matching submodule",
482 relative_path.display()
483 );
484 }
485 let mut guard = if is_gitlink {
486 None
487 } else {
488 let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path);
489 let gitmodules_safe = !gitmodules.is_symlink()
490 && if gitmodules.exists() {
491 git.is_normal_tracked_file(Path::new(".gitmodules"))?
492 } else {
493 !git.has_index_entries(Path::new(".gitmodules"))?
494 };
495 let can_rollback = !path.is_symlink()
496 && !path.exists()
497 && !module_dir.exists()
498 && !git.has_index_entries(relative_path)?
499 && !git.has_submodule_config(relative_path)?
500 && git.is_path_clean(relative_path)?
501 && gitmodules_safe;
502 if !can_rollback {
503 eyre::bail!(
504 "cannot safely install dependency at {} because the target or .gitmodules has existing changes",
505 relative_path.display()
506 );
507 }
508 Some(NewSubmoduleGuard {
509 root,
510 relative_path: relative_path.to_path_buf(),
511 path: path.to_path_buf(),
512 module_dir,
513 gitmodules_contents,
514 armed: true,
515 })
516 };
517
518 self.git_submodule(dep, path)?;
520
521 let mut dep = dep.clone();
522 if dep.tag.is_none() {
523 dep.tag = self.last_tag(path);
525 }
526
527 self.git_checkout(&dep, path, true)?;
529
530 trace!("updating dependency submodules recursively");
531 self.git.root(path).submodule_update(
532 false,
533 false,
534 false,
535 true,
536 std::iter::empty::<PathBuf>(),
537 )?;
538
539 self.git.root(path).submodule_sync()?;
541
542 if let Some(guard) = &mut guard {
543 guard.disarm();
544 }
545 if self.commit {
546 self.git.add_literal(path)?;
547 }
548
549 Ok(dep.tag)
550 }
551
552 fn last_tag(self, path: &Path) -> Option<String> {
553 if self.git.shallow {
554 None
555 } else {
556 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
557 }
558 }
559
560 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
562 let out = self.git.root(path).tag()?;
563 let mut tags = Vec::new();
564 let common_prefixes = &["v-", "v", "release-", "release"];
567 for tag in out.lines() {
568 let mut maybe_semver = tag;
569 for &prefix in common_prefixes {
570 if let Some(rem) = tag.strip_prefix(prefix) {
571 maybe_semver = rem;
572 break;
573 }
574 }
575 match Version::parse(maybe_semver) {
576 Ok(v) => {
577 if v.build.is_empty() && v.pre.is_empty() {
579 tags.push((tag.to_string(), v));
580 }
581 }
582 Err(err) => {
583 warn!(?err, ?maybe_semver, "No semver tag");
584 }
585 }
586 }
587
588 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
589
590 Ok(tags)
591 }
592
593 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
595 let url = dep.require_url()?;
596
597 let path = path.strip_prefix(self.git.root).unwrap();
599
600 trace!(?dep, url, ?path, "installing git submodule");
601 self.git.submodule_add(true, url, path)
602 }
603
604 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
605 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
607
608 let mut is_branch = false;
609 if std::io::stdout().is_terminal() {
611 if tag.is_empty() {
612 tag = self.match_tag(&tag, path)?;
613 } else if let Some(branch) = self.match_branch(&tag, path)? {
614 trace!(?tag, ?branch, "selecting branch for given tag");
615 tag = branch;
616 is_branch = true;
617 }
618 }
619 let url = dep.url.as_ref().unwrap();
620
621 let res = self.git.root(path).checkout(recurse, &tag);
622 if let Err(mut e) = res {
623 fs::remove_dir_all(path)?;
625 if e.to_string().contains("did not match any file(s) known to git") {
626 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
627 }
628 return Err(e);
629 }
630
631 if is_branch { Ok(tag) } else { Ok(String::new()) }
632 }
633
634 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
636 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
638 return Ok(tag.into());
639 }
640
641 let trimmed_tag = tag.trim_start_matches('v').to_string();
645 let output = self.git.root(path).tag()?;
646 let mut candidates: Vec<String> = output
647 .trim()
648 .lines()
649 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
650 .map(|x| x.to_string())
651 .rev()
652 .collect();
653
654 if candidates.is_empty() {
656 return Ok(tag.into());
657 }
658
659 for candidate in &candidates {
661 if candidate == tag {
662 return Ok(tag.into());
663 }
664 }
665
666 if candidates.len() == 1 {
668 let matched_tag = &candidates[0];
669 let input = prompt!(
670 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
671 )?;
672 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
673 }
674
675 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
677 sh_status!("There are multiple matching tags:")?;
678 for (i, candidate) in candidates.iter().enumerate() {
679 sh_status!("[{i}] {candidate}")?;
680 }
681
682 let n_candidates = candidates.len();
683 loop {
684 let input: String =
685 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
686 let s = input.trim();
687 let n = if s.is_empty() { Ok(1) } else { s.parse() };
689 match n {
691 Ok(0) => return Ok(tag.into()),
692 Ok(i) if (1..=n_candidates).contains(&i) => {
693 let c = &candidates[i];
694 sh_status!("[{i}] {c} selected")?;
695 return Ok(c.clone());
696 }
697 _ => {}
698 }
699 }
700 }
701
702 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
703 let output = self.git.root(path).remote_branches()?;
705
706 let mut candidates = output
707 .lines()
708 .map(|x| x.trim().trim_start_matches("origin/"))
709 .filter(|x| x.starts_with(tag))
710 .map(ToString::to_string)
711 .rev()
712 .collect::<Vec<_>>();
713
714 trace!(?candidates, ?tag, "found branch candidates");
715
716 if candidates.is_empty() {
718 return Ok(None);
719 }
720
721 for candidate in &candidates {
723 if candidate == tag {
724 return Ok(Some(tag.to_string()));
725 }
726 }
727
728 if candidates.len() == 1 {
730 let matched_tag = &candidates[0];
731 let input = prompt!(
732 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
733 )?;
734 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
735 }
736
737 candidates.insert(0, format!("{tag} (original branch)"));
739 sh_status!("There are multiple matching branches:")?;
740 for (i, candidate) in candidates.iter().enumerate() {
741 sh_status!("[{i}] {candidate}")?;
742 }
743
744 let n_candidates = candidates.len();
745 let input: String = prompt!(
746 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
747 n_candidates - 1
748 )?;
749 let input = input.trim();
750
751 if input.is_empty() {
753 sh_status!("Canceled branch matching")?;
754 return Ok(None);
755 }
756
757 match input.parse::<usize>() {
759 Ok(0) => Ok(Some(tag.into())),
760 Ok(i) if (1..=n_candidates).contains(&i) => {
761 let c = &candidates[i];
762 sh_status!("[{i}] {c} selected")?;
763 Ok(Some(c.clone()))
764 }
765 _ => Ok(None),
766 }
767 }
768}
769
770fn match_yn(input: String) -> bool {
774 let s = input.trim().to_lowercase();
775 matches!(s.as_str(), "" | "y" | "yes")
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use tempfile::tempdir;
782
783 #[test]
784 #[ignore = "slow"]
785 fn get_oz_tags() {
786 let tmp = tempdir().unwrap();
787 let git = Git::new(tmp.path());
788 let installer = Installer { git, commit: false };
789
790 git.init().unwrap();
791
792 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
793 let libs = tmp.path().join("libs");
794 fs::create_dir(&libs).unwrap();
795 let submodule = libs.join("openzeppelin-contracts");
796 installer.git_submodule(&dep, &submodule).unwrap();
797 assert!(submodule.exists());
798
799 let tags = installer.git_semver_tags(&submodule).unwrap();
800 assert!(!tags.is_empty());
801 let v480: Version = "4.8.0".parse().unwrap();
802 assert!(tags.iter().any(|(_, v)| v == &v480));
803 }
804}