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_println!("Missing dependencies found. Installing now...\n");
108
109 if self.install(config, Vec::new()).await.is_err() {
110 let _ =
111 sh_warn!("Your project has missing dependencies that could not be installed.");
112 }
113 true
114 } else {
115 false
116 }
117 }
118
119 pub async fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
121 let Self { no_git, commit, .. } = self;
122
123 let git = self.git(config);
124
125 let install_lib_dir = config.install_lib_dir();
126 let libs = git.root.join(install_lib_dir);
127
128 let mut lockfile = Lockfile::new(&config.root);
129 if !no_git {
130 lockfile = lockfile.with_git(&git);
131
132 if git.submodules_uninitialized()? {
136 trace!(lib = %libs.display(), "submodules uninitialized");
137 git.submodule_update(false, false, false, true, Some(&libs))?;
138 }
139 }
140
141 let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
142
143 if dependencies.is_empty() && !no_git {
144 let root = Git::root_of(git.root)?;
146 match git.has_submodules(Some(&root)) {
147 Ok(true) => {
148 sh_println!("Updating dependencies in {}", libs.display())?;
149
150 git.submodule_update(false, false, false, true, Some(&libs))?;
152
153 if let Some(out_of_sync) = &out_of_sync_deps {
155 for (rel_path, dep_id) in out_of_sync {
156 git.checkout_at(dep_id.checkout_id(), &git.root.join(rel_path))?;
157 }
158 }
159
160 lockfile.write()?;
161 }
162 Err(err) => {
163 sh_err!("Failed to check for submodules: {err}")?;
164 }
165 _ => {
166 }
168 }
169 }
170
171 fs::create_dir_all(&libs)?;
172
173 let installer = Installer { git, commit };
174 for dep in dependencies {
175 let path = libs.join(dep.name());
176 let rel_path = path
177 .strip_prefix(git.root)
178 .wrap_err("Library directory is not relative to the repository root")?;
179 sh_println!(
180 "Installing {} in {} (url: {}, tag: {})",
181 dep.name,
182 path.display(),
183 dep.url.as_deref().unwrap_or("None"),
184 dep.tag.as_deref().unwrap_or("None")
185 )?;
186
187 let installed_tag;
189 let mut dep_id = None;
190 if no_git {
191 installed_tag = installer.install_as_folder(&dep, &path)?;
192 } else {
193 if commit {
194 git.ensure_clean()?;
195 }
196 installed_tag = installer.install_as_submodule(&dep, &path)?;
197
198 let mut new_insertion = false;
199 if let Some(tag_or_branch) = &installed_tag {
201 dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
203 if git.has_branch(tag_or_branch, &path)?
204 && dep_id.as_ref().is_some_and(|id| id.is_branch())
205 {
206 git.set_submodule_branch(rel_path, tag_or_branch)?;
208
209 let rev = git.get_rev(tag_or_branch, &path)?;
210
211 dep_id = Some(DepIdentifier::Branch {
212 name: tag_or_branch.clone(),
213 rev,
214 r#override: false,
215 });
216 }
217
218 trace!(?dep_id, ?tag_or_branch, "resolved dep id");
219 if let Some(dep_id) = &dep_id {
220 new_insertion = true;
221 lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
222 }
223
224 if commit {
225 let root = Git::root_of(git.root)?;
228 git.root(&root).add(Some(".gitmodules"))?;
229 }
230 }
231
232 if new_insertion
233 || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
234 || !lockfile.exists()
235 {
236 lockfile.write()?;
237 }
238
239 if commit {
241 let mut msg = String::with_capacity(128);
242 msg.push_str("forge install: ");
243 msg.push_str(dep.name());
244
245 if let Some(tag) = &installed_tag {
246 msg.push_str("\n\n");
247
248 if let Some(dep_id) = &dep_id {
249 msg.push_str(&dep_id.to_string());
250 } else {
251 msg.push_str(tag);
252 }
253 }
254
255 if !lockfile.is_empty() {
256 git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
257 }
258 git.commit(&msg)?;
259 }
260 }
261
262 let mut msg = format!(" {} {}", "Installed".green(), dep.name);
263 if let Some(tag) = dep.tag.or(installed_tag) {
264 msg.push(' ');
265
266 if let Some(dep_id) = dep_id {
267 msg.push_str(&dep_id.to_string());
268 } else {
269 msg.push_str(tag.as_str());
270 }
271 }
272 sh_println!("{msg}")?;
273
274 if let Err(e) = install_soldeer_deps_if_needed(&path).await {
276 sh_warn!("Failed to install soldeer dependencies for {}: {e}", dep.name)?;
277 }
278 }
279
280 if !config.libs.iter().any(|p| p == install_lib_dir) {
282 config.libs.push(install_lib_dir.to_path_buf());
283 config.update_libs()?;
284 }
285
286 Ok(())
287 }
288}
289
290pub async fn install_missing_dependencies(config: &mut Config) -> bool {
291 DependencyInstallOpts::default().install_missing_dependencies(config).await
292}
293
294async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> {
296 let soldeer_lock = dep_path.join("soldeer.lock");
297
298 if soldeer_lock.exists() {
299 sh_println!(" Found soldeer.lock, installing soldeer dependencies...")?;
300
301 let original_dir = std::env::current_dir()?;
303 std::env::set_current_dir(dep_path)?;
304
305 let result = soldeer_commands::run(
306 Command::Install(Install::default()),
307 Verbosity::new(
308 foundry_common::shell::verbosity(),
309 if foundry_common::shell::is_quiet() { 1 } else { 0 },
310 ),
311 )
312 .await;
313
314 std::env::set_current_dir(original_dir)?;
316
317 result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?;
318 sh_println!(" Soldeer dependencies installed successfully")?;
319 }
320
321 Ok(())
322}
323
324#[derive(Clone, Copy, Debug)]
325struct Installer<'a> {
326 git: Git<'a>,
327 commit: bool,
328}
329
330impl Installer<'_> {
331 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
333 let url = dep.require_url()?;
334 Git::clone(dep.tag.is_none(), url, Some(&path))?;
335 let mut dep = dep.clone();
336
337 if dep.tag.is_none() {
338 dep.tag = self.last_tag(path);
340 }
341
342 self.git_checkout(&dep, path, true)?;
346
347 trace!("updating dependency submodules recursively");
348 self.git.root(path).submodule_update(
349 false,
350 false,
351 false,
352 true,
353 std::iter::empty::<PathBuf>(),
354 )?;
355
356 Self::remove_nested_git_dirs(path)?;
358
359 fs::remove_dir_all(path.join(".git"))?;
361
362 Ok(dep.tag)
363 }
364
365 fn remove_nested_git_dirs(root: &Path) -> Result<()> {
370 Self::remove_nested_git_dirs_inner(root, root)
371 }
372
373 fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> {
374 let entries = match std::fs::read_dir(dir) {
375 Ok(entries) => entries,
376 Err(_) => return Ok(()),
377 };
378 for entry in entries {
379 let entry = entry?;
380 let ft = entry.file_type()?;
381
382 if ft.is_symlink() {
384 continue;
385 }
386
387 let path = entry.path();
388 if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) {
389 if ft.is_dir() {
390 fs::remove_dir_all(&path)?;
391 } else {
392 fs::remove_file(&path)?;
393 }
394 } else if ft.is_dir() {
395 Self::remove_nested_git_dirs_inner(root, &path)?;
396 }
397 }
398 Ok(())
399 }
400
401 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
406 self.git_submodule(dep, path)?;
408
409 let mut dep = dep.clone();
410 if dep.tag.is_none() {
411 dep.tag = self.last_tag(path);
413 }
414
415 self.git_checkout(&dep, path, true)?;
417
418 trace!("updating dependency submodules recursively");
419 self.git.root(path).submodule_update(
420 false,
421 false,
422 false,
423 true,
424 std::iter::empty::<PathBuf>(),
425 )?;
426
427 self.git.root(path).submodule_sync()?;
429
430 if self.commit {
431 self.git.add(Some(path))?;
432 }
433
434 Ok(dep.tag)
435 }
436
437 fn last_tag(self, path: &Path) -> Option<String> {
438 if self.git.shallow {
439 None
440 } else {
441 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
442 }
443 }
444
445 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
447 let out = self.git.root(path).tag()?;
448 let mut tags = Vec::new();
449 let common_prefixes = &["v-", "v", "release-", "release"];
452 for tag in out.lines() {
453 let mut maybe_semver = tag;
454 for &prefix in common_prefixes {
455 if let Some(rem) = tag.strip_prefix(prefix) {
456 maybe_semver = rem;
457 break;
458 }
459 }
460 match Version::parse(maybe_semver) {
461 Ok(v) => {
462 if v.build.is_empty() && v.pre.is_empty() {
464 tags.push((tag.to_string(), v));
465 }
466 }
467 Err(err) => {
468 warn!(?err, ?maybe_semver, "No semver tag");
469 }
470 }
471 }
472
473 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
474
475 Ok(tags)
476 }
477
478 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
480 let url = dep.require_url()?;
481
482 let path = path.strip_prefix(self.git.root).unwrap();
484
485 trace!(?dep, url, ?path, "installing git submodule");
486 self.git.submodule_add(true, url, path)
487 }
488
489 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
490 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
492
493 let mut is_branch = false;
494 if std::io::stdout().is_terminal() {
496 if tag.is_empty() {
497 tag = self.match_tag(&tag, path)?;
498 } else if let Some(branch) = self.match_branch(&tag, path)? {
499 trace!(?tag, ?branch, "selecting branch for given tag");
500 tag = branch;
501 is_branch = true;
502 }
503 }
504 let url = dep.url.as_ref().unwrap();
505
506 let res = self.git.root(path).checkout(recurse, &tag);
507 if let Err(mut e) = res {
508 fs::remove_dir_all(path)?;
510 if e.to_string().contains("did not match any file(s) known to git") {
511 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
512 }
513 return Err(e);
514 }
515
516 if is_branch { Ok(tag) } else { Ok(String::new()) }
517 }
518
519 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
521 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
523 return Ok(tag.into());
524 }
525
526 let trimmed_tag = tag.trim_start_matches('v').to_string();
530 let output = self.git.root(path).tag()?;
531 let mut candidates: Vec<String> = output
532 .trim()
533 .lines()
534 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
535 .map(|x| x.to_string())
536 .rev()
537 .collect();
538
539 if candidates.is_empty() {
541 return Ok(tag.into());
542 }
543
544 for candidate in &candidates {
546 if candidate == tag {
547 return Ok(tag.into());
548 }
549 }
550
551 if candidates.len() == 1 {
553 let matched_tag = &candidates[0];
554 let input = prompt!(
555 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
556 )?;
557 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
558 }
559
560 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
562 sh_println!("There are multiple matching tags:")?;
563 for (i, candidate) in candidates.iter().enumerate() {
564 sh_println!("[{i}] {candidate}")?;
565 }
566
567 let n_candidates = candidates.len();
568 loop {
569 let input: String =
570 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
571 let s = input.trim();
572 let n = if s.is_empty() { Ok(1) } else { s.parse() };
574 match n {
576 Ok(0) => return Ok(tag.into()),
577 Ok(i) if (1..=n_candidates).contains(&i) => {
578 let c = &candidates[i];
579 sh_println!("[{i}] {c} selected")?;
580 return Ok(c.clone());
581 }
582 _ => {}
583 }
584 }
585 }
586
587 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
588 let output = self.git.root(path).remote_branches()?;
590
591 let mut candidates = output
592 .lines()
593 .map(|x| x.trim().trim_start_matches("origin/"))
594 .filter(|x| x.starts_with(tag))
595 .map(ToString::to_string)
596 .rev()
597 .collect::<Vec<_>>();
598
599 trace!(?candidates, ?tag, "found branch candidates");
600
601 if candidates.is_empty() {
603 return Ok(None);
604 }
605
606 for candidate in &candidates {
608 if candidate == tag {
609 return Ok(Some(tag.to_string()));
610 }
611 }
612
613 if candidates.len() == 1 {
615 let matched_tag = &candidates[0];
616 let input = prompt!(
617 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
618 )?;
619 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
620 }
621
622 candidates.insert(0, format!("{tag} (original branch)"));
624 sh_println!("There are multiple matching branches:")?;
625 for (i, candidate) in candidates.iter().enumerate() {
626 sh_println!("[{i}] {candidate}")?;
627 }
628
629 let n_candidates = candidates.len();
630 let input: String = prompt!(
631 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
632 n_candidates - 1
633 )?;
634 let input = input.trim();
635
636 if input.is_empty() {
638 sh_println!("Canceled branch matching")?;
639 return Ok(None);
640 }
641
642 match input.parse::<usize>() {
644 Ok(0) => Ok(Some(tag.into())),
645 Ok(i) if (1..=n_candidates).contains(&i) => {
646 let c = &candidates[i];
647 sh_println!("[{i}] {c} selected")?;
648 Ok(Some(c.clone()))
649 }
650 _ => Ok(None),
651 }
652 }
653}
654
655fn match_yn(input: String) -> bool {
659 let s = input.trim().to_lowercase();
660 matches!(s.as_str(), "" | "y" | "yes")
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use tempfile::tempdir;
667
668 #[test]
669 #[ignore = "slow"]
670 fn get_oz_tags() {
671 let tmp = tempdir().unwrap();
672 let git = Git::new(tmp.path());
673 let installer = Installer { git, commit: false };
674
675 git.init().unwrap();
676
677 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
678 let libs = tmp.path().join("libs");
679 fs::create_dir(&libs).unwrap();
680 let submodule = libs.join("openzeppelin-contracts");
681 installer.git_submodule(&dep, &submodule).unwrap();
682 assert!(submodule.exists());
683
684 let tags = installer.git_semver_tags(&submodule).unwrap();
685 assert!(!tags.is_empty());
686 let v480: Version = "4.8.0".parse().unwrap();
687 assert!(tags.iter().any(|(_, v)| v == &v480));
688 }
689}