1use crate::{DepIdentifier, FOUNDRY_LOCK, Lockfile};
2use clap::{Parser, ValueHint};
3use eyre::{Context, Result};
4use foundry_cli::{
5 opts::Dependency,
6 utils::{CommandUtils, Git, LoadConfig},
7};
8use foundry_common::fs;
9use foundry_config::{Config, impl_figment_convert_basic};
10use regex::Regex;
11use semver::Version;
12use std::{
13 io::IsTerminal,
14 path::{Path, PathBuf},
15 str,
16 sync::LazyLock,
17};
18use yansi::Paint;
19
20static DEPENDENCY_VERSION_TAG_REGEX: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap());
22
23#[derive(Clone, Debug, Parser)]
25#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]...
26 forge install [OPTIONS] <github username>/<github project>@<tag>...
27 forge install [OPTIONS] <alias>=<github username>/<github project>@<tag>...
28 forge install [OPTIONS] <https://<github token>@git url>...)]
29 forge install [OPTIONS] <https:// git url>...")]
30pub struct InstallArgs {
31 dependencies: Vec<Dependency>,
47
48 #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
53 pub root: Option<PathBuf>,
54
55 #[command(flatten)]
56 opts: DependencyInstallOpts,
57}
58
59impl_figment_convert_basic!(InstallArgs);
60
61impl InstallArgs {
62 pub fn run(self) -> Result<()> {
63 let mut config = self.load_config()?;
64 self.opts.install(&mut config, self.dependencies)
65 }
66}
67
68#[derive(Clone, Copy, Debug, Default, Parser)]
69pub struct DependencyInstallOpts {
70 #[arg(long)]
74 pub shallow: bool,
75
76 #[arg(long)]
78 pub no_git: bool,
79
80 #[arg(long)]
82 pub commit: bool,
83}
84
85impl DependencyInstallOpts {
86 pub fn git(self, config: &Config) -> Git<'_> {
87 Git::from_config(config).shallow(self.shallow)
88 }
89
90 pub fn install_missing_dependencies(self, config: &mut Config) -> bool {
96 let lib = config.install_lib_dir();
97 if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) {
98 let _ = sh_println!("Missing dependencies found. Installing now...\n");
100 if self.install(config, Vec::new()).is_err() {
101 let _ =
102 sh_warn!("Your project has missing dependencies that could not be installed.");
103 }
104 true
105 } else {
106 false
107 }
108 }
109
110 pub fn install(self, config: &mut Config, dependencies: Vec<Dependency>) -> Result<()> {
112 let Self { no_git, commit, .. } = self;
113
114 let git = self.git(config);
115
116 let install_lib_dir = config.install_lib_dir();
117 let libs = git.root.join(install_lib_dir);
118
119 let mut lockfile = Lockfile::new(&config.root);
120 if !no_git {
121 lockfile = lockfile.with_git(&git);
122
123 if git.submodules_uninitialized()? {
127 trace!(lib = %libs.display(), "submodules uninitialized");
128 git.submodule_update(false, false, false, true, Some(&libs))?;
129 }
130 }
131
132 let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?;
133
134 if dependencies.is_empty() && !no_git {
135 let root = Git::root_of(git.root)?;
137 match git.has_submodules(Some(&root)) {
138 Ok(true) => {
139 sh_println!("Updating dependencies in {}", libs.display())?;
140
141 git.submodule_update(false, false, false, true, Some(&libs))?;
143 lockfile.write()?;
144 }
145 Err(err) => {
146 sh_err!("Failed to check for submodules: {err}")?;
147 }
148 _ => {
149 }
151 }
152 }
153
154 fs::create_dir_all(&libs)?;
155
156 let installer = Installer { git, commit };
157 for dep in dependencies {
158 let path = libs.join(dep.name());
159 let rel_path = path
160 .strip_prefix(git.root)
161 .wrap_err("Library directory is not relative to the repository root")?;
162 sh_println!(
163 "Installing {} in {} (url: {:?}, tag: {:?})",
164 dep.name,
165 path.display(),
166 dep.url,
167 dep.tag
168 )?;
169
170 let installed_tag;
172 let mut dep_id = None;
173 if no_git {
174 installed_tag = installer.install_as_folder(&dep, &path)?;
175 } else {
176 if commit {
177 git.ensure_clean()?;
178 }
179 installed_tag = installer.install_as_submodule(&dep, &path)?;
180
181 let mut new_insertion = false;
182 if let Some(tag_or_branch) = &installed_tag {
184 dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
186 if git.has_branch(tag_or_branch, &path)?
187 && dep_id.as_ref().is_some_and(|id| id.is_branch())
188 {
189 git.cmd()
191 .args(["submodule", "set-branch", "-b", tag_or_branch])
192 .arg(rel_path)
193 .exec()?;
194
195 let rev = git.get_rev(tag_or_branch, &path)?;
196
197 dep_id = Some(DepIdentifier::Branch {
198 name: tag_or_branch.to_string(),
199 rev,
200 r#override: false,
201 });
202 }
203
204 trace!(?dep_id, ?tag_or_branch, "resolved dep id");
205 if let Some(dep_id) = &dep_id {
206 new_insertion = true;
207 lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
208 }
209
210 if commit {
211 let root = Git::root_of(git.root)?;
214 git.root(&root).add(Some(".gitmodules"))?;
215 }
216 }
217
218 if new_insertion
219 || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
220 || !lockfile.exists()
221 {
222 lockfile.write()?;
223 }
224
225 if commit {
227 let mut msg = String::with_capacity(128);
228 msg.push_str("forge install: ");
229 msg.push_str(dep.name());
230
231 if let Some(tag) = &installed_tag {
232 msg.push_str("\n\n");
233
234 if let Some(dep_id) = &dep_id {
235 msg.push_str(dep_id.to_string().as_str());
236 } else {
237 msg.push_str(tag);
238 }
239 }
240
241 if !lockfile.is_empty() {
242 git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
243 }
244 git.commit(&msg)?;
245 }
246 }
247
248 let mut msg = format!(" {} {}", "Installed".green(), dep.name);
249 if let Some(tag) = dep.tag.or(installed_tag) {
250 msg.push(' ');
251
252 if let Some(dep_id) = dep_id {
253 msg.push_str(dep_id.to_string().as_str());
254 } else {
255 msg.push_str(tag.as_str());
256 }
257 }
258 sh_println!("{msg}")?;
259 }
260
261 if !config.libs.iter().any(|p| p == install_lib_dir) {
263 config.libs.push(install_lib_dir.to_path_buf());
264 config.update_libs()?;
265 }
266
267 Ok(())
268 }
269}
270
271pub fn install_missing_dependencies(config: &mut Config) -> bool {
272 DependencyInstallOpts::default().install_missing_dependencies(config)
273}
274
275#[derive(Clone, Copy, Debug)]
276struct Installer<'a> {
277 git: Git<'a>,
278 commit: bool,
279}
280
281impl Installer<'_> {
282 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
284 let url = dep.require_url()?;
285 Git::clone(dep.tag.is_none(), url, Some(&path))?;
286 let mut dep = dep.clone();
287
288 if dep.tag.is_none() {
289 dep.tag = self.last_tag(path);
291 }
292
293 self.git_checkout(&dep, path, false)?;
295
296 trace!("updating dependency submodules recursively");
297 self.git.root(path).submodule_update(
298 false,
299 false,
300 false,
301 true,
302 std::iter::empty::<PathBuf>(),
303 )?;
304
305 fs::remove_dir_all(path.join(".git"))?;
307
308 Ok(dep.tag)
309 }
310
311 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
316 self.git_submodule(dep, path)?;
318
319 let mut dep = dep.clone();
320 if dep.tag.is_none() {
321 dep.tag = self.last_tag(path);
323 }
324
325 self.git_checkout(&dep, path, true)?;
327
328 trace!("updating dependency submodules recursively");
329 self.git.root(path).submodule_update(
330 false,
331 false,
332 false,
333 true,
334 std::iter::empty::<PathBuf>(),
335 )?;
336
337 self.git.root(path).submodule_sync()?;
339
340 if self.commit {
341 self.git.add(Some(path))?;
342 }
343
344 Ok(dep.tag)
345 }
346
347 fn last_tag(self, path: &Path) -> Option<String> {
348 if self.git.shallow {
349 None
350 } else {
351 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
352 }
353 }
354
355 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
357 let out = self.git.root(path).tag()?;
358 let mut tags = Vec::new();
359 let common_prefixes = &["v-", "v", "release-", "release"];
362 for tag in out.lines() {
363 let mut maybe_semver = tag;
364 for &prefix in common_prefixes {
365 if let Some(rem) = tag.strip_prefix(prefix) {
366 maybe_semver = rem;
367 break;
368 }
369 }
370 match Version::parse(maybe_semver) {
371 Ok(v) => {
372 if v.build.is_empty() && v.pre.is_empty() {
374 tags.push((tag.to_string(), v));
375 }
376 }
377 Err(err) => {
378 warn!(?err, ?maybe_semver, "No semver tag");
379 }
380 }
381 }
382
383 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
384
385 Ok(tags)
386 }
387
388 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
390 let url = dep.require_url()?;
391
392 let path = path.strip_prefix(self.git.root).unwrap();
394
395 trace!(?dep, url, ?path, "installing git submodule");
396 self.git.submodule_add(true, url, path)
397 }
398
399 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
400 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
402
403 let mut is_branch = false;
404 if std::io::stdout().is_terminal() {
406 if tag.is_empty() {
407 tag = self.match_tag(&tag, path)?;
408 } else if let Some(branch) = self.match_branch(&tag, path)? {
409 trace!(?tag, ?branch, "selecting branch for given tag");
410 tag = branch;
411 is_branch = true;
412 }
413 }
414 let url = dep.url.as_ref().unwrap();
415
416 let res = self.git.root(path).checkout(recurse, &tag);
417 if let Err(mut e) = res {
418 fs::remove_dir_all(path)?;
420 if e.to_string().contains("did not match any file(s) known to git") {
421 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
422 }
423 return Err(e);
424 }
425
426 if is_branch { Ok(tag) } else { Ok(String::new()) }
427 }
428
429 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
431 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
433 return Ok(tag.into());
434 }
435
436 let trimmed_tag = tag.trim_start_matches('v').to_string();
440 let output = self.git.root(path).tag()?;
441 let mut candidates: Vec<String> = output
442 .trim()
443 .lines()
444 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
445 .map(|x| x.to_string())
446 .rev()
447 .collect();
448
449 if candidates.is_empty() {
451 return Ok(tag.into());
452 }
453
454 for candidate in &candidates {
456 if candidate == tag {
457 return Ok(tag.into());
458 }
459 }
460
461 if candidates.len() == 1 {
463 let matched_tag = &candidates[0];
464 let input = prompt!(
465 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
466 )?;
467 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
468 }
469
470 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
472 sh_println!("There are multiple matching tags:")?;
473 for (i, candidate) in candidates.iter().enumerate() {
474 sh_println!("[{i}] {candidate}")?;
475 }
476
477 let n_candidates = candidates.len();
478 loop {
479 let input: String =
480 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
481 let s = input.trim();
482 let n = if s.is_empty() { Ok(1) } else { s.parse() };
484 match n {
486 Ok(0) => return Ok(tag.into()),
487 Ok(i) if (1..=n_candidates).contains(&i) => {
488 let c = &candidates[i];
489 sh_println!("[{i}] {c} selected")?;
490 return Ok(c.clone());
491 }
492 _ => continue,
493 }
494 }
495 }
496
497 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
498 let output = self.git.root(path).cmd().args(["branch", "-r"]).get_stdout_lossy()?;
500
501 let mut candidates = output
502 .lines()
503 .map(|x| x.trim().trim_start_matches("origin/"))
504 .filter(|x| x.starts_with(tag))
505 .map(ToString::to_string)
506 .rev()
507 .collect::<Vec<_>>();
508
509 trace!(?candidates, ?tag, "found branch candidates");
510
511 if candidates.is_empty() {
513 return Ok(None);
514 }
515
516 for candidate in &candidates {
518 if candidate == tag {
519 return Ok(Some(tag.to_string()));
520 }
521 }
522
523 if candidates.len() == 1 {
525 let matched_tag = &candidates[0];
526 let input = prompt!(
527 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
528 )?;
529 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
530 }
531
532 candidates.insert(0, format!("{tag} (original branch)"));
534 sh_println!("There are multiple matching branches:")?;
535 for (i, candidate) in candidates.iter().enumerate() {
536 sh_println!("[{i}] {candidate}")?;
537 }
538
539 let n_candidates = candidates.len();
540 let input: String = prompt!(
541 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
542 n_candidates - 1
543 )?;
544 let input = input.trim();
545
546 if input.is_empty() {
548 sh_println!("Canceled branch matching")?;
549 return Ok(None);
550 }
551
552 match input.parse::<usize>() {
554 Ok(0) => Ok(Some(tag.into())),
555 Ok(i) if (1..=n_candidates).contains(&i) => {
556 let c = &candidates[i];
557 sh_println!("[{i}] {c} selected")?;
558 Ok(Some(c.clone()))
559 }
560 _ => Ok(None),
561 }
562 }
563}
564
565fn match_yn(input: String) -> bool {
569 let s = input.trim().to_lowercase();
570 matches!(s.as_str(), "" | "y" | "yes")
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use tempfile::tempdir;
577
578 #[test]
579 #[ignore = "slow"]
580 fn get_oz_tags() {
581 let tmp = tempdir().unwrap();
582 let git = Git::new(tmp.path());
583 let installer = Installer { git, commit: false };
584
585 git.init().unwrap();
586
587 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
588 let libs = tmp.path().join("libs");
589 fs::create_dir(&libs).unwrap();
590 let submodule = libs.join("openzeppelin-contracts");
591 installer.git_submodule(&dep, &submodule).unwrap();
592 assert!(submodule.exists());
593
594 let tags = installer.git_semver_tags(&submodule).unwrap();
595 assert!(!tags.is_empty());
596 let v480: Version = "4.8.0".parse().unwrap();
597 assert!(tags.iter().any(|(_, v)| v == &v480));
598 }
599}