1use crate::{
4 lockfile::{DepIdentifier, DepMap, FOUNDRY_LOCK, Lockfile},
5 opts::{Dependency, DependencyRefKind},
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 std::{
15 io::{ErrorKind, IsTerminal},
16 path::{Path, PathBuf},
17 str,
18 sync::LazyLock,
19};
20use yansi::Paint;
21
22#[cfg(feature = "soldeer")]
23use soldeer_commands::{Command, Verbosity, commands::install::Install};
24
25static DEPENDENCY_VERSION_TAG_REGEX: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap());
27
28#[derive(Clone, Debug, Parser)]
30#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]...
31 forge install [OPTIONS] <github username>/<github project>@<tag>...
32 forge install [OPTIONS] <alias>=<github username>/<github project>@<tag>...
33 forge install [OPTIONS] <https://<github token>@git url>...)]
34 forge install [OPTIONS] <https:// git url>...")]
35pub struct InstallArgs {
36 dependencies: Vec<Dependency>,
52
53 #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
58 pub root: Option<PathBuf>,
59
60 #[arg(long, hide = true)]
65 pub no_commit: bool,
66
67 #[command(flatten)]
68 opts: DependencyInstallOpts,
69}
70
71impl_figment_convert_basic!(InstallArgs);
72
73impl InstallArgs {
74 pub async fn run(mut self) -> Result<()> {
75 if self.root.is_none() {
76 self.root = std::env::current_dir()?
77 .ancestors()
78 .find(|root| root.join(Config::FILE_NAME).is_file())
79 .map(Path::to_path_buf);
80 }
81 let mut config = self.load_config()?;
82 self.opts.install(&mut config, self.dependencies).await
83 }
84}
85
86#[derive(Clone, Copy, Debug, Default, Parser)]
87pub struct DependencyInstallOpts {
88 #[arg(long)]
92 pub shallow: bool,
93
94 #[arg(long)]
96 pub no_git: bool,
97
98 #[arg(long)]
100 pub commit: bool,
101}
102
103impl DependencyInstallOpts {
104 pub fn git(self, config: &Config) -> Git<'_> {
105 Git::from_config(config).shallow(self.shallow)
106 }
107
108 pub fn install_missing_dependencies(self, config: &mut Config) -> bool {
114 let lib = config.install_lib_dir();
115 if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
116 let _ = sh_status!("Missing dependencies found. Installing now...");
117
118 if self.install_existing_dependencies(config).is_err() {
119 let _ =
120 sh_warn!("Your project has missing dependencies that could not be installed.");
121 }
122 true
123 } else {
124 false
125 }
126 }
127
128 fn install_existing_dependencies(self, config: &mut Config) -> Result<()> {
130 let git = self.git(config);
131 let install_lib_dir = config.install_lib_dir();
132 let libs = git.root.join(install_lib_dir);
133 let (lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
134
135 if !self.no_git {
136 let root = Git::root_of(git.root)?;
138 match git.has_submodules(Some(&root)) {
139 Ok(true) => {
140 sh_status!("Updating dependencies in {}", libs.display())?;
141
142 git.submodule_update(false, false, false, true, Some(&libs))?;
144
145 if let Some(out_of_sync) = &out_of_sync_deps {
147 for (rel_path, dep_id) in out_of_sync {
148 let path = git.root.join(rel_path);
149 git.checkout_at(dep_id.checkout_id(), &path).wrap_err_with(|| {
150 let dep = rel_path.display();
151 let mut on_disk = Lockfile::new(&config.root);
153 let hint =
154 if on_disk.read().is_ok() && on_disk.get(rel_path).is_some() {
155 format!(
156 "\n`{dep}` is locked to {dep_id} in {FOUNDRY_LOCK}. If \
157 that ref is no longer available from the dependency's \
158 remote, update the `{dep}` entry in {FOUNDRY_LOCK}."
159 )
160 } else if let DepIdentifier::Branch { name, .. } = dep_id {
161 format!(
162 "\nBranch `{name}` comes from the `branch` setting for \
163 `{dep}` in .gitmodules. If it no longer exists on the \
164 dependency's remote, update .gitmodules; if it does, \
165 the local clone may be missing it, for example after \
166 a `--shallow` install."
167 )
168 } else {
169 String::new()
170 };
171 format!(
172 "Failed to check out `{}` for dependency `{dep}`{hint}",
173 dep_id.checkout_id()
174 )
175 })?;
176 }
177 }
178
179 lockfile.write()?;
180 }
181 Err(err) => {
182 sh_err!("Failed to check for submodules: {err}")?;
183 }
184 _ => {
185 }
187 }
188 }
189
190 fs::create_dir_all(&libs)?;
191
192 if !config.libs.iter().any(|p| p == install_lib_dir) {
194 config.libs.push(install_lib_dir.to_path_buf());
195 config.update_libs()?;
196 }
197
198 Ok(())
199 }
200
201 fn sync_lockfile<'a>(
202 self,
203 config: &Config,
204 git: &'a Git<'_>,
205 ) -> Result<(Lockfile<'a>, Option<DepMap>)> {
206 let libs = git.root.join(config.install_lib_dir());
207 let mut lockfile = Lockfile::new(&config.root);
208 if !self.no_git {
209 lockfile = lockfile.with_git(git);
210
211 if git.submodules_uninitialized()? {
215 trace!(lib = %libs.display(), "submodules uninitialized");
216 git.submodule_update(false, false, false, true, Some(&libs))?;
217 }
218 }
219
220 let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
221
222 Ok((lockfile, out_of_sync_deps))
223 }
224
225 pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
227 if dependencies.is_empty() {
228 return self.install_existing_dependencies(config);
229 }
230
231 let Self { no_git, commit, .. } = self;
232
233 let git = self.git(config);
234
235 let install_lib_dir = config.install_lib_dir();
236 let libs = git.root.join(install_lib_dir);
237
238 let (mut lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?;
239
240 fs::create_dir_all(&libs)?;
241
242 let installer = Installer { git, commit };
243 for dep in dependencies {
244 if dep
245 .name()
246 .split(['/', '\\'])
247 .any(|component| component.is_empty() || matches!(component, "." | ".."))
248 {
249 eyre::bail!("invalid dependency name: {}", dep.name());
250 }
251 let path = libs.join(dep.name());
252 let rel_path = path
253 .strip_prefix(git.root)
254 .wrap_err("Library directory is not relative to the repository root")?;
255 sh_status!(
256 "Installing {} in {} (url: {}, tag: {})",
257 dep.name,
258 path.display(),
259 dep.url.as_deref().unwrap_or("None"),
260 dep.tag.as_deref().unwrap_or("None")
261 )?;
262
263 let installed_tag;
265 let mut dep_id = None;
266 if no_git {
267 installed_tag = installer.install_as_folder(&dep, &path)?;
268 } else {
269 if commit {
270 git.ensure_clean()?;
271 }
272 installed_tag = installer.install_as_submodule(&dep, &path)?;
273
274 let mut new_insertion = false;
275 if let Some(tag_or_branch) = &installed_tag {
277 dep_id = Some(match dep.ref_kind {
278 Some(DependencyRefKind::Branch) => DepIdentifier::Branch {
279 name: tag_or_branch.clone(),
280 rev: git.root(&path).head()?,
281 r#override: false,
282 },
283 Some(DependencyRefKind::Tag) => DepIdentifier::Tag {
284 name: tag_or_branch.clone(),
285 rev: git.root(&path).head()?,
286 r#override: false,
287 },
288 Some(DependencyRefKind::Rev) => {
289 DepIdentifier::Rev { rev: git.root(&path).head()?, r#override: false }
290 }
291 None => DepIdentifier::resolve_type(&git, &path, tag_or_branch)?,
292 });
293 if git.has_branch(tag_or_branch, &path)?
294 && dep_id.as_ref().is_some_and(|id| id.is_branch())
295 {
296 git.set_submodule_branch(rel_path, tag_or_branch)?;
298 let root = Git::root_of(git.root)?;
299 git.root(&root).add_literal(Path::new(".gitmodules"))?;
300 }
301
302 trace!(?dep_id, ?tag_or_branch, "resolved dep id");
303 if let Some(dep_id) = &dep_id {
304 new_insertion = true;
305 lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
306 }
307
308 if commit {
309 let root = Git::root_of(git.root)?;
312 git.root(&root).add(Some(".gitmodules"))?;
313 }
314 }
315
316 if new_insertion
317 || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
318 || !lockfile.exists()
319 {
320 lockfile.write()?;
321 }
322
323 if commit {
325 let mut msg = String::with_capacity(128);
326 msg.push_str("forge install: ");
327 msg.push_str(dep.name());
328
329 if let Some(tag) = &installed_tag {
330 msg.push_str("\n\n");
331
332 if let Some(dep_id) = &dep_id {
333 msg.push_str(&dep_id.to_string());
334 } else {
335 msg.push_str(tag);
336 }
337 }
338
339 if !lockfile.is_empty() {
340 git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
341 }
342 git.commit(&msg)?;
343 }
344 }
345
346 let mut msg = format!(" {} {}", "Installed".green(), dep.name);
347 if let Some(tag) = dep.tag.or(installed_tag) {
348 msg.push(' ');
349
350 if let Some(dep_id) = dep_id {
351 msg.push_str(&dep_id.to_string());
352 } else {
353 msg.push_str(tag.as_str());
354 }
355 }
356 sh_status!("{msg}")?;
357
358 install_soldeer_deps_if_needed(&path).await.map_err(|e| {
360 eyre::eyre!(
361 "Failed to install soldeer dependencies for {}: {e}\n\
362 Run `forge soldeer install` in {} to retry.",
363 dep.name,
364 path.display()
365 )
366 })?;
367 }
368
369 if !config.libs.iter().any(|p| p == install_lib_dir) {
371 config.libs.push(install_lib_dir.to_path_buf());
372 config.update_libs()?;
373 }
374
375 Ok(())
376 }
377}
378
379pub fn install_missing_dependencies<E>(
381 config: &mut Config,
382 reload: impl FnOnce() -> Result<Config, E>,
383) -> Result<(), E> {
384 if DependencyInstallOpts::default().install_missing_dependencies(config)
385 && config.auto_detect_remappings
386 {
387 *config = reload()?;
388 }
389 Ok(())
390}
391
392#[cfg(feature = "soldeer")]
394async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
395 let soldeer_lock = dep_path.join("soldeer.lock");
396
397 if soldeer_lock.exists() {
398 sh_status!(" Found soldeer.lock, installing soldeer dependencies...")?;
399
400 let original_dir = std::env::current_dir()?;
402 std::env::set_current_dir(dep_path)?;
403
404 let result = soldeer_commands::run(
405 Command::Install(Install::default()),
406 Verbosity::new(
407 foundry_common::shell::verbosity(),
408 if foundry_common::shell::is_quiet() { 1 } else { 0 },
409 ),
410 )
411 .await;
412
413 std::env::set_current_dir(original_dir)?;
415
416 result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
417 sh_status!(" Soldeer dependencies installed successfully")?;
418 }
419
420 Ok(())
421}
422
423#[cfg(not(feature = "soldeer"))]
425async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
426 if dep_path.join("soldeer.lock").exists() {
427 sh_warn!(
428 " Found soldeer.lock, but this build does not include soldeer; \
429 skipping soldeer dependencies"
430 )?;
431 }
432
433 Ok(())
434}
435
436#[derive(Clone, Copy, Debug)]
437struct Installer<'a> {
438 git: Git<'a>,
439 commit: bool,
440}
441
442struct NewSubmoduleGuard {
443 root: PathBuf,
444 relative_path: PathBuf,
445 path: PathBuf,
446 module_dir: PathBuf,
447 gitmodules_contents: Option<Vec<u8>>,
448 armed: bool,
449}
450
451impl NewSubmoduleGuard {
452 const fn disarm(&mut self) {
453 self.armed = false;
454 }
455
456 fn rollback(&self) {
457 let git = Git::new(&self.root);
458 if let Err(err) = git.remove_index_path(&self.relative_path) {
459 warn!(%err, "failed to remove submodule after installation failure");
460 }
461 if self.path.exists()
462 && let Err(err) = fs::remove_dir_all(&self.path)
463 {
464 warn!(%err, "failed to remove dependency after installation failure");
465 }
466 if self.module_dir.exists()
467 && let Err(err) = fs::remove_dir_all(&self.module_dir)
468 {
469 warn!(%err, "failed to remove submodule Git directory after installation failure");
470 }
471 if let Err(err) = git.remove_submodule_config(&self.relative_path) {
472 warn!(%err, "failed to remove submodule config after installation failure");
473 }
474 restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref());
475 if let Err(err) = git.add_literal(Path::new(".gitmodules")) {
476 warn!(%err, "failed to restore staged .gitmodules after installation failure");
477 }
478 }
479}
480
481impl Drop for NewSubmoduleGuard {
482 fn drop(&mut self) {
483 if self.armed {
484 self.rollback();
485 }
486 }
487}
488
489struct NewFolderGuard<'a> {
491 path: &'a Path,
492 armed: bool,
493}
494
495impl<'a> NewFolderGuard<'a> {
496 fn new(path: &'a Path) -> Result<Self> {
497 if let Some(parent) = path.parent() {
498 fs::create_dir_all(parent)?;
499 }
500 fs::create_dir(path)?;
502 Ok(Self { path, armed: true })
503 }
504
505 const fn disarm(&mut self) {
506 self.armed = false;
507 }
508}
509
510impl Drop for NewFolderGuard<'_> {
511 fn drop(&mut self) {
512 if self.armed
513 && let Err(err) = std::fs::remove_dir_all(self.path)
514 && err.kind() != ErrorKind::NotFound
515 {
516 warn!(%err, path = %self.path.display(), "failed to remove dependency after installation failure");
517 }
518 }
519}
520
521fn restore_file(path: &Path, contents: Option<&[u8]>) {
522 let result = match contents {
523 Some(contents) => fs::write(path, contents),
524 None if path.exists() => fs::remove_file(path),
525 None => Ok(()),
526 };
527 if let Err(err) = result {
528 warn!(%err, path = %path.display(), "failed to restore file after installation failure");
529 }
530}
531
532impl Installer<'_> {
533 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
535 let url = dep.require_url()?;
536 let mut guard = NewFolderGuard::new(path)?;
537 Git::clone(dep.tag.is_none(), url, Some(&path))?;
538 let mut dep = dep.clone();
539
540 if dep.tag.is_none() {
541 dep.tag = self.last_tag(path);
543 }
544
545 self.git_checkout(&dep, path, true)?;
549
550 trace!("updating dependency submodules recursively");
551 self.git.root(path).submodule_update(
552 false,
553 false,
554 false,
555 true,
556 std::iter::empty::<PathBuf>(),
557 )?;
558
559 Self::remove_nested_git_dirs(path)?;
561
562 fs::remove_dir_all(path.join(".git"))?;
564
565 guard.disarm();
566 Ok(dep.tag)
567 }
568
569 fn remove_nested_git_dirs(root: &Path) -> Result<()> {
574 Self::remove_nested_git_dirs_inner(root, root)
575 }
576
577 fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
578 let entries = match std::fs::read_dir(dir) {
579 Ok(entries) => entries,
580 Err(_) => return Ok(()),
581 };
582 for entry in entries {
583 let entry = entry?;
584 let ft = entry.file_type()?;
585
586 if ft.is_symlink() {
588 continue;
589 }
590
591 let path = entry.path();
592 if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
593 if ft.is_dir() {
594 fs::remove_dir_all(&path)?;
595 } else {
596 fs::remove_file(&path)?;
597 }
598 } else if ft.is_dir() {
599 Self::remove_nested_git_dirs_inner(root, &path)?;
600 }
601 }
602 Ok(())
603 }
604
605 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
610 let root = Git::root_of(self.git.root)?;
611 let relative_path = path.strip_prefix(&root)?;
612 let git = self.git.root(&root);
613 let gitmodules = root.join(".gitmodules");
614 let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?;
615 let has_mapping = git.has_submodule_mapping(relative_path)?;
616 let is_gitlink = git.is_gitlink(relative_path)?;
617 if has_mapping != is_gitlink {
618 eyre::bail!(
619 "cannot safely install dependency at {} because .gitmodules already contains a matching submodule",
620 relative_path.display()
621 );
622 }
623 let mut guard = if is_gitlink {
624 None
625 } else {
626 let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path);
627 let gitmodules_safe = !gitmodules.is_symlink()
628 && if gitmodules.exists() {
629 git.is_normal_tracked_file(Path::new(".gitmodules"))?
630 } else {
631 !git.has_index_entries(Path::new(".gitmodules"))?
632 };
633 let can_rollback = !path.is_symlink()
634 && !path.exists()
635 && !module_dir.exists()
636 && !git.has_index_entries(relative_path)?
637 && !git.has_submodule_config(relative_path)?
638 && git.is_path_clean(relative_path)?
639 && gitmodules_safe;
640 if !can_rollback {
641 eyre::bail!(
642 "cannot safely install dependency at {} because the target or .gitmodules has existing changes",
643 relative_path.display()
644 );
645 }
646 Some(NewSubmoduleGuard {
647 root,
648 relative_path: relative_path.to_path_buf(),
649 path: path.to_path_buf(),
650 module_dir,
651 gitmodules_contents,
652 armed: true,
653 })
654 };
655
656 self.git_submodule(dep, path)?;
658
659 let mut dep = dep.clone();
660 if dep.tag.is_none() {
661 dep.tag = self.last_tag(path);
663 }
664
665 self.git_checkout(&dep, path, true)?;
667
668 trace!("updating dependency submodules recursively");
669 self.git.root(path).submodule_update(
670 false,
671 false,
672 false,
673 true,
674 std::iter::empty::<PathBuf>(),
675 )?;
676
677 self.git.root(path).submodule_sync()?;
679
680 if let Some(guard) = &mut guard {
681 guard.disarm();
682 }
683 if self.commit {
684 self.git.add_literal(path)?;
685 }
686
687 Ok(dep.tag)
688 }
689
690 fn last_tag(self, path: &Path) -> Option<String> {
691 if self.git.shallow {
692 None
693 } else {
694 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
695 }
696 }
697
698 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
700 let out = self.git.root(path).tag()?;
701 let mut tags = Vec::new();
702 let common_prefixes = &["v-", "v", "release-", "release"];
705 for tag in out.lines() {
706 let mut maybe_semver = tag;
707 for &prefix in common_prefixes {
708 if let Some(rem) = tag.strip_prefix(prefix) {
709 maybe_semver = rem;
710 break;
711 }
712 }
713 match Version::parse(maybe_semver) {
714 Ok(v) => {
715 if v.build.is_empty() && v.pre.is_empty() {
717 tags.push((tag.to_string(), v));
718 }
719 }
720 Err(err) => {
721 warn!(?err, ?maybe_semver, "No semver tag");
722 }
723 }
724 }
725
726 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
727
728 Ok(tags)
729 }
730
731 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
733 let url = dep.require_url()?;
734
735 let path = path.strip_prefix(self.git.root).unwrap();
737
738 trace!(?dep, url, ?path, "installing git submodule");
739 self.git.submodule_add(true, url, path)
740 }
741
742 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<()> {
743 let Some(mut tag) = dep.tag.clone() else { return Ok(()) };
745
746 if dep.ref_kind.is_none() && std::io::stdout().is_terminal() {
748 if tag.is_empty() {
749 tag = self.match_tag(&tag, path)?;
750 } else if let Some(branch) = self.match_branch(&tag, path)? {
751 trace!(?tag, ?branch, "selecting branch for given tag");
752 tag = branch;
753 }
754 }
755 let url = dep.url.as_ref().unwrap();
756
757 let checkout_ref = match dep.ref_kind {
760 Some(DependencyRefKind::Tag) => format!("refs/tags/{tag}"),
761 Some(DependencyRefKind::Branch | DependencyRefKind::Rev) => tag.clone(),
762 None if dep.tag.as_deref() == Some(tag.as_str()) && self.git.has_tag(&tag, path)? => {
763 format!("refs/tags/{tag}")
764 }
765 None => tag.clone(),
766 };
767 let res = if dep.ref_kind == Some(DependencyRefKind::Branch) {
768 self.git.root(path).checkout_branch(recurse, &tag)
769 } else {
770 self.git.root(path).checkout(recurse, checkout_ref)
771 };
772 if let Err(mut e) = res {
773 fs::remove_dir_all(path)?;
775 let error = e.to_string();
776 if error.contains("did not match any file(s) known to git")
777 || error.contains("fatal: invalid reference:")
778 {
779 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
780 }
781 return Err(e);
782 }
783
784 Ok(())
785 }
786
787 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
789 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
791 return Ok(tag.into());
792 }
793
794 let trimmed_tag = tag.trim_start_matches('v').to_string();
798 let output = self.git.root(path).tag()?;
799 let mut candidates: Vec<String> = output
800 .trim()
801 .lines()
802 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
803 .map(|x| x.to_string())
804 .rev()
805 .collect();
806
807 if candidates.is_empty() {
809 return Ok(tag.into());
810 }
811
812 for candidate in &candidates {
814 if candidate == tag {
815 return Ok(tag.into());
816 }
817 }
818
819 if candidates.len() == 1 {
821 let matched_tag = &candidates[0];
822 let input = prompt!(
823 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
824 )?;
825 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
826 }
827
828 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
830 sh_status!("There are multiple matching tags:")?;
831 for (i, candidate) in candidates.iter().enumerate() {
832 sh_status!("[{i}] {candidate}")?;
833 }
834
835 let n_candidates = candidates.len();
836 loop {
837 let input: String =
838 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
839 let s = input.trim();
840 let n = if s.is_empty() { Ok(1) } else { s.parse() };
842 match n {
844 Ok(0) => return Ok(tag.into()),
845 Ok(i) if (1..=n_candidates).contains(&i) => {
846 let c = &candidates[i];
847 sh_status!("[{i}] {c} selected")?;
848 return Ok(c.clone());
849 }
850 _ => {}
851 }
852 }
853 }
854
855 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
856 let output = self.git.root(path).remote_branches()?;
858
859 let mut candidates = output
860 .lines()
861 .map(|x| x.trim().trim_start_matches("origin/"))
862 .filter(|x| x.starts_with(tag))
863 .map(ToString::to_string)
864 .rev()
865 .collect::<Vec<_>>();
866
867 trace!(?candidates, ?tag, "found branch candidates");
868
869 if candidates.is_empty() {
871 return Ok(None);
872 }
873
874 for candidate in &candidates {
876 if candidate == tag {
877 return Ok(Some(tag.to_string()));
878 }
879 }
880
881 if candidates.len() == 1 {
883 let matched_tag = &candidates[0];
884 let input = prompt!(
885 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
886 )?;
887 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
888 }
889
890 candidates.insert(0, format!("{tag} (original branch)"));
892 sh_status!("There are multiple matching branches:")?;
893 for (i, candidate) in candidates.iter().enumerate() {
894 sh_status!("[{i}] {candidate}")?;
895 }
896
897 let n_candidates = candidates.len();
898 let input: String = prompt!(
899 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
900 n_candidates - 1
901 )?;
902 let input = input.trim();
903
904 if input.is_empty() {
906 sh_status!("Canceled branch matching")?;
907 return Ok(None);
908 }
909
910 match input.parse::<usize>() {
912 Ok(0) => Ok(Some(tag.into())),
913 Ok(i) if (1..=n_candidates).contains(&i) => {
914 let c = &candidates[i];
915 sh_status!("[{i}] {c} selected")?;
916 Ok(Some(c.clone()))
917 }
918 _ => Ok(None),
919 }
920 }
921}
922
923fn match_yn(input: String) -> bool {
927 let s = input.trim().to_lowercase();
928 matches!(s.as_str(), "" | "y" | "yes")
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use tempfile::tempdir;
935
936 #[test]
937 #[ignore = "slow"]
938 fn get_oz_tags() {
939 let tmp = tempdir().unwrap();
940 let git = Git::new(tmp.path());
941 let installer = Installer { git, commit: false };
942
943 git.init().unwrap();
944
945 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
946 let libs = tmp.path().join("libs");
947 fs::create_dir(&libs).unwrap();
948 let submodule = libs.join("openzeppelin-contracts");
949 installer.git_submodule(&dep, &submodule).unwrap();
950 assert!(submodule.exists());
951
952 let tags = installer.git_semver_tags(&submodule).unwrap();
953 assert!(!tags.is_empty());
954 let v480: Version = "4.8.0".parse().unwrap();
955 assert!(tags.iter().any(|(_, v)| v == &v480));
956 }
957}