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