1use crate::{
4 lockfile::{DepIdentifier, DepMap, FOUNDRY_LOCK, Lockfile},
5 opts::Dependency,
6 utils::{Git, LoadConfig},
7};
8use clap::{Parser, ValueHint};
9use eyre::{Context, Result};
10use foundry_common::fs;
11use foundry_config::{Config, impl_figment_convert_basic};
12use regex::Regex;
13use semver::Version;
14use soldeer_commands::{Command, Verbosity, commands::install::Install};
15use std::{
16 io::IsTerminal,
17 path::{Path, PathBuf},
18 str,
19 sync::LazyLock,
20};
21use yansi::Paint;
22
23static DEPENDENCY_VERSION_TAG_REGEX: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap());
25
26#[derive(Clone, Debug, Parser)]
28#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]...
29 forge install [OPTIONS] <github username>/<github project>@<tag>...
30 forge install [OPTIONS] <alias>=<github username>/<github project>@<tag>...
31 forge install [OPTIONS] <https://<github token>@git url>...)]
32 forge install [OPTIONS] <https:// git url>...")]
33pub struct InstallArgs {
34 dependencies: Vec<Dependency>,
50
51 #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
56 pub root: Option<PathBuf>,
57
58 #[arg(long, hide = true)]
63 pub no_commit: bool,
64
65 #[command(flatten)]
66 opts: DependencyInstallOpts,
67}
68
69impl_figment_convert_basic!(InstallArgs);
70
71impl InstallArgs {
72 pub async fn run(mut self) -> Result<()> {
73 if self.root.is_none() {
74 self.root = std::env::current_dir()?
75 .ancestors()
76 .find(|root| root.join(Config::FILE_NAME).is_file())
77 .map(Path::to_path_buf);
78 }
79 let mut config = self.load_config()?;
80 self.opts.install(&mut config, self.dependencies).await
81 }
82}
83
84#[derive(Clone, Copy, Debug, Default, Parser)]
85pub struct DependencyInstallOpts {
86 #[arg(long)]
90 pub shallow: bool,
91
92 #[arg(long)]
94 pub no_git: bool,
95
96 #[arg(long)]
98 pub commit: bool,
99}
100
101impl DependencyInstallOpts {
102 pub fn git(self, config: &Config) -> Git<'_> {
103 Git::from_config(config).shallow(self.shallow)
104 }
105
106 pub fn install_missing_dependencies(self, config: &mut Config) -> bool {
112 let lib = config.install_lib_dir();
113 if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
114 let _ = sh_status!("Missing dependencies found. Installing now...");
115
116 if self.install_existing_dependencies(config).is_err() {
117 let _ =
118 sh_warn!("Your project has missing dependencies that could not be installed.");
119 }
120 true
121 } else {
122 false
123 }
124 }
125
126 fn install_existing_dependencies(self, config: &mut Config) -> Result<()> {
128 let git = self.git(config);
129 let install_lib_dir = config.install_lib_dir();
130 let libs = git.root.join(install_lib_dir);
131 let (lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
132
133 if !self.no_git {
134 let root = Git::root_of(git.root)?;
136 match git.has_submodules(Some(&root)) {
137 Ok(true) => {
138 sh_status!("Updating dependencies in {}", libs.display())?;
139
140 git.submodule_update(false, false, false, true, Some(&libs))?;
142
143 if let Some(out_of_sync) = &out_of_sync_deps {
145 for (rel_path, dep_id) in out_of_sync {
146 git.checkout_at(dep_id.checkout_id(), &git.root.join(rel_path))?;
147 }
148 }
149
150 lockfile.write()?;
151 }
152 Err(err) => {
153 sh_err!("Failed to check for submodules: {err}")?;
154 }
155 _ => {
156 }
158 }
159 }
160
161 fs::create_dir_all(&libs)?;
162
163 if !config.libs.iter().any(|p| p == install_lib_dir) {
165 config.libs.push(install_lib_dir.to_path_buf());
166 config.update_libs()?;
167 }
168
169 Ok(())
170 }
171
172 fn sync_lockfile<'a>(
173 self,
174 config: &Config,
175 git: &'a Git<'_>,
176 ) -> Result<(Lockfile<'a>, Option<DepMap>)> {
177 let libs = git.root.join(config.install_lib_dir());
178 let mut lockfile = Lockfile::new(&config.root);
179 if !self.no_git {
180 lockfile = lockfile.with_git(git);
181
182 if git.submodules_uninitialized()? {
186 trace!(lib = %libs.display(), "submodules uninitialized");
187 git.submodule_update(false, false, false, true, Some(&libs))?;
188 }
189 }
190
191 let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
192
193 Ok((lockfile, out_of_sync_deps))
194 }
195
196 pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
198 if dependencies.is_empty() {
199 return self.install_existing_dependencies(config);
200 }
201
202 let Self { no_git, commit, .. } = self;
203
204 let git = self.git(config);
205
206 let install_lib_dir = config.install_lib_dir();
207 let libs = git.root.join(install_lib_dir);
208
209 let (mut lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
210
211 fs::create_dir_all(&libs)?;
212
213 let installer = Installer { git, commit };
214 for dep in dependencies {
215 if dep
216 .name()
217 .split(['/', '\\'])
218 .any(|component| component.is_empty() || matches!(component, "." | ".."))
219 {
220 eyre::bail!("invalid dependency name: {}", dep.name());
221 }
222 let path = libs.join(dep.name());
223 let rel_path = path
224 .strip_prefix(git.root)
225 .wrap_err("Library directory is not relative to the repository root")?;
226 sh_status!(
227 "Installing {} in {} (url: {}, tag: {})",
228 dep.name,
229 path.display(),
230 dep.url.as_deref().unwrap_or("None"),
231 dep.tag.as_deref().unwrap_or("None")
232 )?;
233
234 let installed_tag;
236 let mut dep_id = None;
237 if no_git {
238 installed_tag = installer.install_as_folder(&dep, &path)?;
239 } else {
240 if commit {
241 git.ensure_clean()?;
242 }
243 installed_tag = installer.install_as_submodule(&dep, &path)?;
244
245 let mut new_insertion = false;
246 if let Some(tag_or_branch) = &installed_tag {
248 dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
250 if git.has_branch(tag_or_branch, &path)?
251 && dep_id.as_ref().is_some_and(|id| id.is_branch())
252 {
253 git.set_submodule_branch(rel_path, tag_or_branch)?;
255 let root = Git::root_of(git.root)?;
256 git.root(&root).add_literal(Path::new(".gitmodules"))?;
257
258 let rev = git.get_rev(tag_or_branch, &path)?;
259
260 dep_id = Some(DepIdentifier::Branch {
261 name: tag_or_branch.clone(),
262 rev,
263 r#override: false,
264 });
265 }
266
267 trace!(?dep_id, ?tag_or_branch, "resolved dep id");
268 if let Some(dep_id) = &dep_id {
269 new_insertion = true;
270 lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
271 }
272
273 if commit {
274 let root = Git::root_of(git.root)?;
277 git.root(&root).add(Some(".gitmodules"))?;
278 }
279 }
280
281 if new_insertion
282 || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
283 || !lockfile.exists()
284 {
285 lockfile.write()?;
286 }
287
288 if commit {
290 let mut msg = String::with_capacity(128);
291 msg.push_str("forge install: ");
292 msg.push_str(dep.name());
293
294 if let Some(tag) = &installed_tag {
295 msg.push_str("\n\n");
296
297 if let Some(dep_id) = &dep_id {
298 msg.push_str(&dep_id.to_string());
299 } else {
300 msg.push_str(tag);
301 }
302 }
303
304 if !lockfile.is_empty() {
305 git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
306 }
307 git.commit(&msg)?;
308 }
309 }
310
311 let mut msg = format!(" {} {}", "Installed".green(), dep.name);
312 if let Some(tag) = dep.tag.or(installed_tag) {
313 msg.push(' ');
314
315 if let Some(dep_id) = dep_id {
316 msg.push_str(&dep_id.to_string());
317 } else {
318 msg.push_str(tag.as_str());
319 }
320 }
321 sh_status!("{msg}")?;
322
323 if let Err(e) = install_soldeer_deps_if_needed(&path).await {
325 sh_warn!("Failed to install soldeer dependencies for {}: {e}", dep.name)?;
326 }
327 }
328
329 if !config.libs.iter().any(|p| p == install_lib_dir) {
331 config.libs.push(install_lib_dir.to_path_buf());
332 config.update_libs()?;
333 }
334
335 Ok(())
336 }
337}
338
339pub fn install_missing_dependencies<E>(
341 config: &mut Config,
342 reload: impl FnOnce() -> Result<Config, E>,
343) -> Result<(), E> {
344 if DependencyInstallOpts::default().install_missing_dependencies(config)
345 && config.auto_detect_remappings
346 {
347 *config = reload()?;
348 }
349 Ok(())
350}
351
352async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
354 let soldeer_lock = dep_path.join("soldeer.lock");
355
356 if soldeer_lock.exists() {
357 sh_status!(" Found soldeer.lock, installing soldeer dependencies...")?;
358
359 let original_dir = std::env::current_dir()?;
361 std::env::set_current_dir(dep_path)?;
362
363 let result = soldeer_commands::run(
364 Command::Install(Install::default()),
365 Verbosity::new(
366 foundry_common::shell::verbosity(),
367 if foundry_common::shell::is_quiet() { 1 } else { 0 },
368 ),
369 )
370 .await;
371
372 std::env::set_current_dir(original_dir)?;
374
375 result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
376 sh_status!(" Soldeer dependencies installed successfully")?;
377 }
378
379 Ok(())
380}
381
382#[derive(Clone, Copy, Debug)]
383struct Installer<'a> {
384 git: Git<'a>,
385 commit: bool,
386}
387
388struct NewSubmoduleGuard {
389 root: PathBuf,
390 relative_path: PathBuf,
391 path: PathBuf,
392 module_dir: PathBuf,
393 gitmodules_contents: Option<Vec<u8>>,
394 armed: bool,
395}
396
397impl NewSubmoduleGuard {
398 const fn disarm(&mut self) {
399 self.armed = false;
400 }
401
402 fn rollback(&self) {
403 let git = Git::new(&self.root);
404 if let Err(err) = git.remove_index_path(&self.relative_path) {
405 warn!(%err, "failed to remove submodule after installation failure");
406 }
407 if self.path.exists()
408 && let Err(err) = fs::remove_dir_all(&self.path)
409 {
410 warn!(%err, "failed to remove dependency after installation failure");
411 }
412 if self.module_dir.exists()
413 && let Err(err) = fs::remove_dir_all(&self.module_dir)
414 {
415 warn!(%err, "failed to remove submodule Git directory after installation failure");
416 }
417 if let Err(err) = git.remove_submodule_config(&self.relative_path) {
418 warn!(%err, "failed to remove submodule config after installation failure");
419 }
420 restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref());
421 if let Err(err) = git.add_literal(Path::new(".gitmodules")) {
422 warn!(%err, "failed to restore staged .gitmodules after installation failure");
423 }
424 }
425}
426
427impl Drop for NewSubmoduleGuard {
428 fn drop(&mut self) {
429 if self.armed {
430 self.rollback();
431 }
432 }
433}
434
435fn restore_file(path: &Path, contents: Option<&[u8]>) {
436 let result = match contents {
437 Some(contents) => fs::write(path, contents),
438 None if path.exists() => fs::remove_file(path),
439 None => Ok(()),
440 };
441 if let Err(err) = result {
442 warn!(%err, path = %path.display(), "failed to restore file after installation failure");
443 }
444}
445
446impl Installer<'_> {
447 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
449 let url = dep.require_url()?;
450 Git::clone(dep.tag.is_none(), url, Some(&path))?;
451 let mut dep = dep.clone();
452
453 if dep.tag.is_none() {
454 dep.tag = self.last_tag(path);
456 }
457
458 self.git_checkout(&dep, path, true)?;
462
463 trace!("updating dependency submodules recursively");
464 self.git.root(path).submodule_update(
465 false,
466 false,
467 false,
468 true,
469 std::iter::empty::<PathBuf>(),
470 )?;
471
472 Self::remove_nested_git_dirs(path)?;
474
475 fs::remove_dir_all(path.join(".git"))?;
477
478 Ok(dep.tag)
479 }
480
481 fn remove_nested_git_dirs(root: &Path) -> Result<()> {
486 Self::remove_nested_git_dirs_inner(root, root)
487 }
488
489 fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
490 let entries = match std::fs::read_dir(dir) {
491 Ok(entries) => entries,
492 Err(_) => return Ok(()),
493 };
494 for entry in entries {
495 let entry = entry?;
496 let ft = entry.file_type()?;
497
498 if ft.is_symlink() {
500 continue;
501 }
502
503 let path = entry.path();
504 if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
505 if ft.is_dir() {
506 fs::remove_dir_all(&path)?;
507 } else {
508 fs::remove_file(&path)?;
509 }
510 } else if ft.is_dir() {
511 Self::remove_nested_git_dirs_inner(root, &path)?;
512 }
513 }
514 Ok(())
515 }
516
517 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
522 let root = Git::root_of(self.git.root)?;
523 let relative_path = path.strip_prefix(&root)?;
524 let git = self.git.root(&root);
525 let gitmodules = root.join(".gitmodules");
526 let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?;
527 let has_mapping = git.has_submodule_mapping(relative_path)?;
528 let is_gitlink = git.is_gitlink(relative_path)?;
529 if has_mapping != is_gitlink {
530 eyre::bail!(
531 "cannot safely install dependency at {} because .gitmodules already contains a matching submodule",
532 relative_path.display()
533 );
534 }
535 let mut guard = if is_gitlink {
536 None
537 } else {
538 let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path);
539 let gitmodules_safe = !gitmodules.is_symlink()
540 && if gitmodules.exists() {
541 git.is_normal_tracked_file(Path::new(".gitmodules"))?
542 } else {
543 !git.has_index_entries(Path::new(".gitmodules"))?
544 };
545 let can_rollback = !path.is_symlink()
546 && !path.exists()
547 && !module_dir.exists()
548 && !git.has_index_entries(relative_path)?
549 && !git.has_submodule_config(relative_path)?
550 && git.is_path_clean(relative_path)?
551 && gitmodules_safe;
552 if !can_rollback {
553 eyre::bail!(
554 "cannot safely install dependency at {} because the target or .gitmodules has existing changes",
555 relative_path.display()
556 );
557 }
558 Some(NewSubmoduleGuard {
559 root,
560 relative_path: relative_path.to_path_buf(),
561 path: path.to_path_buf(),
562 module_dir,
563 gitmodules_contents,
564 armed: true,
565 })
566 };
567
568 self.git_submodule(dep, path)?;
570
571 let mut dep = dep.clone();
572 if dep.tag.is_none() {
573 dep.tag = self.last_tag(path);
575 }
576
577 self.git_checkout(&dep, path, true)?;
579
580 trace!("updating dependency submodules recursively");
581 self.git.root(path).submodule_update(
582 false,
583 false,
584 false,
585 true,
586 std::iter::empty::<PathBuf>(),
587 )?;
588
589 self.git.root(path).submodule_sync()?;
591
592 if let Some(guard) = &mut guard {
593 guard.disarm();
594 }
595 if self.commit {
596 self.git.add_literal(path)?;
597 }
598
599 Ok(dep.tag)
600 }
601
602 fn last_tag(self, path: &Path) -> Option<String> {
603 if self.git.shallow {
604 None
605 } else {
606 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
607 }
608 }
609
610 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
612 let out = self.git.root(path).tag()?;
613 let mut tags = Vec::new();
614 let common_prefixes = &["v-", "v", "release-", "release"];
617 for tag in out.lines() {
618 let mut maybe_semver = tag;
619 for &prefix in common_prefixes {
620 if let Some(rem) = tag.strip_prefix(prefix) {
621 maybe_semver = rem;
622 break;
623 }
624 }
625 match Version::parse(maybe_semver) {
626 Ok(v) => {
627 if v.build.is_empty() && v.pre.is_empty() {
629 tags.push((tag.to_string(), v));
630 }
631 }
632 Err(err) => {
633 warn!(?err, ?maybe_semver, "No semver tag");
634 }
635 }
636 }
637
638 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
639
640 Ok(tags)
641 }
642
643 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
645 let url = dep.require_url()?;
646
647 let path = path.strip_prefix(self.git.root).unwrap();
649
650 trace!(?dep, url, ?path, "installing git submodule");
651 self.git.submodule_add(true, url, path)
652 }
653
654 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
655 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
657
658 let mut is_branch = false;
659 if std::io::stdout().is_terminal() {
661 if tag.is_empty() {
662 tag = self.match_tag(&tag, path)?;
663 } else if let Some(branch) = self.match_branch(&tag, path)? {
664 trace!(?tag, ?branch, "selecting branch for given tag");
665 tag = branch;
666 is_branch = true;
667 }
668 }
669 let url = dep.url.as_ref().unwrap();
670
671 let res = self.git.root(path).checkout(recurse, &tag);
672 if let Err(mut e) = res {
673 fs::remove_dir_all(path)?;
675 if e.to_string().contains("did not match any file(s) known to git") {
676 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
677 }
678 return Err(e);
679 }
680
681 if is_branch { Ok(tag) } else { Ok(String::new()) }
682 }
683
684 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
686 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
688 return Ok(tag.into());
689 }
690
691 let trimmed_tag = tag.trim_start_matches('v').to_string();
695 let output = self.git.root(path).tag()?;
696 let mut candidates: Vec<String> = output
697 .trim()
698 .lines()
699 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
700 .map(|x| x.to_string())
701 .rev()
702 .collect();
703
704 if candidates.is_empty() {
706 return Ok(tag.into());
707 }
708
709 for candidate in &candidates {
711 if candidate == tag {
712 return Ok(tag.into());
713 }
714 }
715
716 if candidates.len() == 1 {
718 let matched_tag = &candidates[0];
719 let input = prompt!(
720 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
721 )?;
722 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
723 }
724
725 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
727 sh_status!("There are multiple matching tags:")?;
728 for (i, candidate) in candidates.iter().enumerate() {
729 sh_status!("[{i}] {candidate}")?;
730 }
731
732 let n_candidates = candidates.len();
733 loop {
734 let input: String =
735 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
736 let s = input.trim();
737 let n = if s.is_empty() { Ok(1) } else { s.parse() };
739 match n {
741 Ok(0) => return Ok(tag.into()),
742 Ok(i) if (1..=n_candidates).contains(&i) => {
743 let c = &candidates[i];
744 sh_status!("[{i}] {c} selected")?;
745 return Ok(c.clone());
746 }
747 _ => {}
748 }
749 }
750 }
751
752 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
753 let output = self.git.root(path).remote_branches()?;
755
756 let mut candidates = output
757 .lines()
758 .map(|x| x.trim().trim_start_matches("origin/"))
759 .filter(|x| x.starts_with(tag))
760 .map(ToString::to_string)
761 .rev()
762 .collect::<Vec<_>>();
763
764 trace!(?candidates, ?tag, "found branch candidates");
765
766 if candidates.is_empty() {
768 return Ok(None);
769 }
770
771 for candidate in &candidates {
773 if candidate == tag {
774 return Ok(Some(tag.to_string()));
775 }
776 }
777
778 if candidates.len() == 1 {
780 let matched_tag = &candidates[0];
781 let input = prompt!(
782 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
783 )?;
784 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
785 }
786
787 candidates.insert(0, format!("{tag} (original branch)"));
789 sh_status!("There are multiple matching branches:")?;
790 for (i, candidate) in candidates.iter().enumerate() {
791 sh_status!("[{i}] {candidate}")?;
792 }
793
794 let n_candidates = candidates.len();
795 let input: String = prompt!(
796 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
797 n_candidates - 1
798 )?;
799 let input = input.trim();
800
801 if input.is_empty() {
803 sh_status!("Canceled branch matching")?;
804 return Ok(None);
805 }
806
807 match input.parse::<usize>() {
809 Ok(0) => Ok(Some(tag.into())),
810 Ok(i) if (1..=n_candidates).contains(&i) => {
811 let c = &candidates[i];
812 sh_status!("[{i}] {c} selected")?;
813 Ok(Some(c.clone()))
814 }
815 _ => Ok(None),
816 }
817 }
818}
819
820fn match_yn(input: String) -> bool {
824 let s = input.trim().to_lowercase();
825 matches!(s.as_str(), "" | "y" | "yes")
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831 use tempfile::tempdir;
832
833 #[test]
834 #[ignore = "slow"]
835 fn get_oz_tags() {
836 let tmp = tempdir().unwrap();
837 let git = Git::new(tmp.path());
838 let installer = Installer { git, commit: false };
839
840 git.init().unwrap();
841
842 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
843 let libs = tmp.path().join("libs");
844 fs::create_dir(&libs).unwrap();
845 let submodule = libs.join("openzeppelin-contracts");
846 installer.git_submodule(&dep, &submodule).unwrap();
847 assert!(submodule.exists());
848
849 let tags = installer.git_semver_tags(&submodule).unwrap();
850 assert!(!tags.is_empty());
851 let v480: Version = "4.8.0".parse().unwrap();
852 assert!(tags.iter().any(|(_, v)| v == &v480));
853 }
854}