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::{ErrorKind, 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 install_soldeer_deps_if_needed(&path).await.map_err(|e| {
325 eyre::eyre!(
326 "Failed to install soldeer dependencies for {}: {e}\n\
327 Run `forge soldeer install` in {} to retry.",
328 dep.name,
329 path.display()
330 )
331 })?;
332 }
333
334 if !config.libs.iter().any(|p| p == install_lib_dir) {
336 config.libs.push(install_lib_dir.to_path_buf());
337 config.update_libs()?;
338 }
339
340 Ok(())
341 }
342}
343
344pub fn install_missing_dependencies<E>(
346 config: &mut Config,
347 reload: impl FnOnce() -> Result<Config, E>,
348) -> Result<(), E> {
349 if DependencyInstallOpts::default().install_missing_dependencies(config)
350 && config.auto_detect_remappings
351 {
352 *config = reload()?;
353 }
354 Ok(())
355}
356
357async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
359 let soldeer_lock = dep_path.join("soldeer.lock");
360
361 if soldeer_lock.exists() {
362 sh_status!(" Found soldeer.lock, installing soldeer dependencies...")?;
363
364 let original_dir = std::env::current_dir()?;
366 std::env::set_current_dir(dep_path)?;
367
368 let result = soldeer_commands::run(
369 Command::Install(Install::default()),
370 Verbosity::new(
371 foundry_common::shell::verbosity(),
372 if foundry_common::shell::is_quiet() { 1 } else { 0 },
373 ),
374 )
375 .await;
376
377 std::env::set_current_dir(original_dir)?;
379
380 result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
381 sh_status!(" Soldeer dependencies installed successfully")?;
382 }
383
384 Ok(())
385}
386
387#[derive(Clone, Copy, Debug)]
388struct Installer<'a> {
389 git: Git<'a>,
390 commit: bool,
391}
392
393struct NewSubmoduleGuard {
394 root: PathBuf,
395 relative_path: PathBuf,
396 path: PathBuf,
397 module_dir: PathBuf,
398 gitmodules_contents: Option<Vec<u8>>,
399 armed: bool,
400}
401
402impl NewSubmoduleGuard {
403 const fn disarm(&mut self) {
404 self.armed = false;
405 }
406
407 fn rollback(&self) {
408 let git = Git::new(&self.root);
409 if let Err(err) = git.remove_index_path(&self.relative_path) {
410 warn!(%err, "failed to remove submodule after installation failure");
411 }
412 if self.path.exists()
413 && let Err(err) = fs::remove_dir_all(&self.path)
414 {
415 warn!(%err, "failed to remove dependency after installation failure");
416 }
417 if self.module_dir.exists()
418 && let Err(err) = fs::remove_dir_all(&self.module_dir)
419 {
420 warn!(%err, "failed to remove submodule Git directory after installation failure");
421 }
422 if let Err(err) = git.remove_submodule_config(&self.relative_path) {
423 warn!(%err, "failed to remove submodule config after installation failure");
424 }
425 restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref());
426 if let Err(err) = git.add_literal(Path::new(".gitmodules")) {
427 warn!(%err, "failed to restore staged .gitmodules after installation failure");
428 }
429 }
430}
431
432impl Drop for NewSubmoduleGuard {
433 fn drop(&mut self) {
434 if self.armed {
435 self.rollback();
436 }
437 }
438}
439
440struct NewFolderGuard<'a> {
442 path: &'a Path,
443 armed: bool,
444}
445
446impl<'a> NewFolderGuard<'a> {
447 fn new(path: &'a Path) -> Result<Self> {
448 if let Some(parent) = path.parent() {
449 fs::create_dir_all(parent)?;
450 }
451 fs::create_dir(path)?;
453 Ok(Self { path, armed: true })
454 }
455
456 const fn disarm(&mut self) {
457 self.armed = false;
458 }
459}
460
461impl Drop for NewFolderGuard<'_> {
462 fn drop(&mut self) {
463 if self.armed
464 && let Err(err) = std::fs::remove_dir_all(self.path)
465 && err.kind() != ErrorKind::NotFound
466 {
467 warn!(%err, path = %self.path.display(), "failed to remove dependency after installation failure");
468 }
469 }
470}
471
472fn restore_file(path: &Path, contents: Option<&[u8]>) {
473 let result = match contents {
474 Some(contents) => fs::write(path, contents),
475 None if path.exists() => fs::remove_file(path),
476 None => Ok(()),
477 };
478 if let Err(err) = result {
479 warn!(%err, path = %path.display(), "failed to restore file after installation failure");
480 }
481}
482
483impl Installer<'_> {
484 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
486 let url = dep.require_url()?;
487 let mut guard = NewFolderGuard::new(path)?;
488 Git::clone(dep.tag.is_none(), url, Some(&path))?;
489 let mut dep = dep.clone();
490
491 if dep.tag.is_none() {
492 dep.tag = self.last_tag(path);
494 }
495
496 self.git_checkout(&dep, path, true)?;
500
501 trace!("updating dependency submodules recursively");
502 self.git.root(path).submodule_update(
503 false,
504 false,
505 false,
506 true,
507 std::iter::empty::<PathBuf>(),
508 )?;
509
510 Self::remove_nested_git_dirs(path)?;
512
513 fs::remove_dir_all(path.join(".git"))?;
515
516 guard.disarm();
517 Ok(dep.tag)
518 }
519
520 fn remove_nested_git_dirs(root: &Path) -> Result<()> {
525 Self::remove_nested_git_dirs_inner(root, root)
526 }
527
528 fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
529 let entries = match std::fs::read_dir(dir) {
530 Ok(entries) => entries,
531 Err(_) => return Ok(()),
532 };
533 for entry in entries {
534 let entry = entry?;
535 let ft = entry.file_type()?;
536
537 if ft.is_symlink() {
539 continue;
540 }
541
542 let path = entry.path();
543 if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
544 if ft.is_dir() {
545 fs::remove_dir_all(&path)?;
546 } else {
547 fs::remove_file(&path)?;
548 }
549 } else if ft.is_dir() {
550 Self::remove_nested_git_dirs_inner(root, &path)?;
551 }
552 }
553 Ok(())
554 }
555
556 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
561 let root = Git::root_of(self.git.root)?;
562 let relative_path = path.strip_prefix(&root)?;
563 let git = self.git.root(&root);
564 let gitmodules = root.join(".gitmodules");
565 let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?;
566 let has_mapping = git.has_submodule_mapping(relative_path)?;
567 let is_gitlink = git.is_gitlink(relative_path)?;
568 if has_mapping != is_gitlink {
569 eyre::bail!(
570 "cannot safely install dependency at {} because .gitmodules already contains a matching submodule",
571 relative_path.display()
572 );
573 }
574 let mut guard = if is_gitlink {
575 None
576 } else {
577 let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path);
578 let gitmodules_safe = !gitmodules.is_symlink()
579 && if gitmodules.exists() {
580 git.is_normal_tracked_file(Path::new(".gitmodules"))?
581 } else {
582 !git.has_index_entries(Path::new(".gitmodules"))?
583 };
584 let can_rollback = !path.is_symlink()
585 && !path.exists()
586 && !module_dir.exists()
587 && !git.has_index_entries(relative_path)?
588 && !git.has_submodule_config(relative_path)?
589 && git.is_path_clean(relative_path)?
590 && gitmodules_safe;
591 if !can_rollback {
592 eyre::bail!(
593 "cannot safely install dependency at {} because the target or .gitmodules has existing changes",
594 relative_path.display()
595 );
596 }
597 Some(NewSubmoduleGuard {
598 root,
599 relative_path: relative_path.to_path_buf(),
600 path: path.to_path_buf(),
601 module_dir,
602 gitmodules_contents,
603 armed: true,
604 })
605 };
606
607 self.git_submodule(dep, path)?;
609
610 let mut dep = dep.clone();
611 if dep.tag.is_none() {
612 dep.tag = self.last_tag(path);
614 }
615
616 self.git_checkout(&dep, path, true)?;
618
619 trace!("updating dependency submodules recursively");
620 self.git.root(path).submodule_update(
621 false,
622 false,
623 false,
624 true,
625 std::iter::empty::<PathBuf>(),
626 )?;
627
628 self.git.root(path).submodule_sync()?;
630
631 if let Some(guard) = &mut guard {
632 guard.disarm();
633 }
634 if self.commit {
635 self.git.add_literal(path)?;
636 }
637
638 Ok(dep.tag)
639 }
640
641 fn last_tag(self, path: &Path) -> Option<String> {
642 if self.git.shallow {
643 None
644 } else {
645 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
646 }
647 }
648
649 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
651 let out = self.git.root(path).tag()?;
652 let mut tags = Vec::new();
653 let common_prefixes = &["v-", "v", "release-", "release"];
656 for tag in out.lines() {
657 let mut maybe_semver = tag;
658 for &prefix in common_prefixes {
659 if let Some(rem) = tag.strip_prefix(prefix) {
660 maybe_semver = rem;
661 break;
662 }
663 }
664 match Version::parse(maybe_semver) {
665 Ok(v) => {
666 if v.build.is_empty() && v.pre.is_empty() {
668 tags.push((tag.to_string(), v));
669 }
670 }
671 Err(err) => {
672 warn!(?err, ?maybe_semver, "No semver tag");
673 }
674 }
675 }
676
677 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
678
679 Ok(tags)
680 }
681
682 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
684 let url = dep.require_url()?;
685
686 let path = path.strip_prefix(self.git.root).unwrap();
688
689 trace!(?dep, url, ?path, "installing git submodule");
690 self.git.submodule_add(true, url, path)
691 }
692
693 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
694 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
696
697 let mut is_branch = false;
698 if std::io::stdout().is_terminal() {
700 if tag.is_empty() {
701 tag = self.match_tag(&tag, path)?;
702 } else if let Some(branch) = self.match_branch(&tag, path)? {
703 trace!(?tag, ?branch, "selecting branch for given tag");
704 tag = branch;
705 is_branch = true;
706 }
707 }
708 let url = dep.url.as_ref().unwrap();
709
710 let res = self.git.root(path).checkout(recurse, &tag);
711 if let Err(mut e) = res {
712 fs::remove_dir_all(path)?;
714 let error = e.to_string();
715 if error.contains("did not match any file(s) known to git")
716 || error.contains("fatal: invalid reference:")
717 {
718 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
719 }
720 return Err(e);
721 }
722
723 if is_branch { Ok(tag) } else { Ok(String::new()) }
724 }
725
726 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
728 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
730 return Ok(tag.into());
731 }
732
733 let trimmed_tag = tag.trim_start_matches('v').to_string();
737 let output = self.git.root(path).tag()?;
738 let mut candidates: Vec<String> = output
739 .trim()
740 .lines()
741 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
742 .map(|x| x.to_string())
743 .rev()
744 .collect();
745
746 if candidates.is_empty() {
748 return Ok(tag.into());
749 }
750
751 for candidate in &candidates {
753 if candidate == tag {
754 return Ok(tag.into());
755 }
756 }
757
758 if candidates.len() == 1 {
760 let matched_tag = &candidates[0];
761 let input = prompt!(
762 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
763 )?;
764 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
765 }
766
767 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
769 sh_status!("There are multiple matching tags:")?;
770 for (i, candidate) in candidates.iter().enumerate() {
771 sh_status!("[{i}] {candidate}")?;
772 }
773
774 let n_candidates = candidates.len();
775 loop {
776 let input: String =
777 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
778 let s = input.trim();
779 let n = if s.is_empty() { Ok(1) } else { s.parse() };
781 match n {
783 Ok(0) => return Ok(tag.into()),
784 Ok(i) if (1..=n_candidates).contains(&i) => {
785 let c = &candidates[i];
786 sh_status!("[{i}] {c} selected")?;
787 return Ok(c.clone());
788 }
789 _ => {}
790 }
791 }
792 }
793
794 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
795 let output = self.git.root(path).remote_branches()?;
797
798 let mut candidates = output
799 .lines()
800 .map(|x| x.trim().trim_start_matches("origin/"))
801 .filter(|x| x.starts_with(tag))
802 .map(ToString::to_string)
803 .rev()
804 .collect::<Vec<_>>();
805
806 trace!(?candidates, ?tag, "found branch candidates");
807
808 if candidates.is_empty() {
810 return Ok(None);
811 }
812
813 for candidate in &candidates {
815 if candidate == tag {
816 return Ok(Some(tag.to_string()));
817 }
818 }
819
820 if candidates.len() == 1 {
822 let matched_tag = &candidates[0];
823 let input = prompt!(
824 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
825 )?;
826 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
827 }
828
829 candidates.insert(0, format!("{tag} (original branch)"));
831 sh_status!("There are multiple matching branches:")?;
832 for (i, candidate) in candidates.iter().enumerate() {
833 sh_status!("[{i}] {candidate}")?;
834 }
835
836 let n_candidates = candidates.len();
837 let input: String = prompt!(
838 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
839 n_candidates - 1
840 )?;
841 let input = input.trim();
842
843 if input.is_empty() {
845 sh_status!("Canceled branch matching")?;
846 return Ok(None);
847 }
848
849 match input.parse::<usize>() {
851 Ok(0) => Ok(Some(tag.into())),
852 Ok(i) if (1..=n_candidates).contains(&i) => {
853 let c = &candidates[i];
854 sh_status!("[{i}] {c} selected")?;
855 Ok(Some(c.clone()))
856 }
857 _ => Ok(None),
858 }
859 }
860}
861
862fn match_yn(input: String) -> bool {
866 let s = input.trim().to_lowercase();
867 matches!(s.as_str(), "" | "y" | "yes")
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use tempfile::tempdir;
874
875 #[test]
876 #[ignore = "slow"]
877 fn get_oz_tags() {
878 let tmp = tempdir().unwrap();
879 let git = Git::new(tmp.path());
880 let installer = Installer { git, commit: false };
881
882 git.init().unwrap();
883
884 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
885 let libs = tmp.path().join("libs");
886 fs::create_dir(&libs).unwrap();
887 let submodule = libs.join("openzeppelin-contracts");
888 installer.git_submodule(&dep, &submodule).unwrap();
889 assert!(submodule.exists());
890
891 let tags = installer.git_semver_tags(&submodule).unwrap();
892 assert!(!tags.is_empty());
893 let v480: Version = "4.8.0".parse().unwrap();
894 assert!(tags.iter().any(|(_, v)| v == &v480));
895 }
896}