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_unintialized()? {
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
146 Err(err) => {
147 warn!(?err, "Failed to check for submodules");
148 }
149 _ => {
150 }
152 }
153 }
154
155 fs::create_dir_all(&libs)?;
156
157 let installer = Installer { git, commit };
158 for dep in dependencies {
159 let path = libs.join(dep.name());
160 let rel_path = path
161 .strip_prefix(git.root)
162 .wrap_err("Library directory is not relative to the repository root")?;
163 sh_println!(
164 "Installing {} in {} (url: {:?}, tag: {:?})",
165 dep.name,
166 path.display(),
167 dep.url,
168 dep.tag
169 )?;
170
171 let installed_tag;
173 let mut dep_id = None;
174 if no_git {
175 installed_tag = installer.install_as_folder(&dep, &path)?;
176 } else {
177 if commit {
178 git.ensure_clean()?;
179 }
180 installed_tag = installer.install_as_submodule(&dep, &path)?;
181
182 let mut new_insertion = false;
183 if let Some(tag_or_branch) = &installed_tag {
185 dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?);
187 if git.has_branch(tag_or_branch, &path)?
188 && dep_id.as_ref().is_some_and(|id| id.is_branch())
189 {
190 git.cmd()
192 .args(["submodule", "set-branch", "-b", tag_or_branch])
193 .arg(rel_path)
194 .exec()?;
195
196 let rev = git.get_rev(tag_or_branch, &path)?;
197
198 dep_id = Some(DepIdentifier::Branch {
199 name: tag_or_branch.to_string(),
200 rev,
201 r#override: false,
202 });
203 }
204
205 trace!(?dep_id, ?tag_or_branch, "resolved dep id");
206 if let Some(dep_id) = &dep_id {
207 new_insertion = true;
208 lockfile.insert(rel_path.to_path_buf(), dep_id.clone());
209 }
210
211 if commit {
212 let root = Git::root_of(git.root)?;
215 git.root(&root).add(Some(".gitmodules"))?;
216 }
217 }
218
219 if new_insertion
220 || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty())
221 || !lockfile.exists()
222 {
223 lockfile.write()?;
224 }
225
226 if commit {
228 let mut msg = String::with_capacity(128);
229 msg.push_str("forge install: ");
230 msg.push_str(dep.name());
231
232 if let Some(tag) = &installed_tag {
233 msg.push_str("\n\n");
234
235 if let Some(dep_id) = &dep_id {
236 msg.push_str(dep_id.to_string().as_str());
237 } else {
238 msg.push_str(tag);
239 }
240 }
241
242 if !lockfile.is_empty() {
243 git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
244 }
245 git.commit(&msg)?;
246 }
247 }
248
249 let mut msg = format!(" {} {}", "Installed".green(), dep.name);
250 if let Some(tag) = dep.tag.or(installed_tag) {
251 msg.push(' ');
252
253 if let Some(dep_id) = dep_id {
254 msg.push_str(dep_id.to_string().as_str());
255 } else {
256 msg.push_str(tag.as_str());
257 }
258 }
259 sh_println!("{msg}")?;
260 }
261
262 if !config.libs.iter().any(|p| p == install_lib_dir) {
264 config.libs.push(install_lib_dir.to_path_buf());
265 config.update_libs()?;
266 }
267
268 Ok(())
269 }
270}
271
272pub fn install_missing_dependencies(config: &mut Config) -> bool {
273 DependencyInstallOpts::default().install_missing_dependencies(config)
274}
275
276#[derive(Clone, Copy, Debug)]
277struct Installer<'a> {
278 git: Git<'a>,
279 commit: bool,
280}
281
282impl Installer<'_> {
283 fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
285 let url = dep.require_url()?;
286 Git::clone(dep.tag.is_none(), url, Some(&path))?;
287 let mut dep = dep.clone();
288
289 if dep.tag.is_none() {
290 dep.tag = self.last_tag(path);
292 }
293
294 self.git_checkout(&dep, path, false)?;
296
297 trace!("updating dependency submodules recursively");
298 self.git.root(path).submodule_update(
299 false,
300 false,
301 false,
302 true,
303 std::iter::empty::<PathBuf>(),
304 )?;
305
306 fs::remove_dir_all(path.join(".git"))?;
308
309 Ok(dep.tag)
310 }
311
312 fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result<Option<String>> {
317 self.git_submodule(dep, path)?;
319
320 let mut dep = dep.clone();
321 if dep.tag.is_none() {
322 dep.tag = self.last_tag(path);
324 }
325
326 self.git_checkout(&dep, path, true)?;
328
329 trace!("updating dependency submodules recursively");
330 self.git.root(path).submodule_update(
331 false,
332 false,
333 false,
334 true,
335 std::iter::empty::<PathBuf>(),
336 )?;
337
338 self.git.root(path).submodule_sync()?;
340
341 if self.commit {
342 self.git.add(Some(path))?;
343 }
344
345 Ok(dep.tag)
346 }
347
348 fn last_tag(self, path: &Path) -> Option<String> {
349 if self.git.shallow {
350 None
351 } else {
352 self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag)
353 }
354 }
355
356 fn git_semver_tags(self, path: &Path) -> Result<Vec<(String, Version)>> {
358 let out = self.git.root(path).tag()?;
359 let mut tags = Vec::new();
360 let common_prefixes = &["v-", "v", "release-", "release"];
363 for tag in out.lines() {
364 let mut maybe_semver = tag;
365 for &prefix in common_prefixes {
366 if let Some(rem) = tag.strip_prefix(prefix) {
367 maybe_semver = rem;
368 break;
369 }
370 }
371 match Version::parse(maybe_semver) {
372 Ok(v) => {
373 if v.build.is_empty() && v.pre.is_empty() {
375 tags.push((tag.to_string(), v));
376 }
377 }
378 Err(err) => {
379 warn!(?err, ?maybe_semver, "No semver tag");
380 }
381 }
382 }
383
384 tags.sort_by(|(_, a), (_, b)| a.cmp(b));
385
386 Ok(tags)
387 }
388
389 fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> {
391 let url = dep.require_url()?;
392
393 let path = path.strip_prefix(self.git.root).unwrap();
395
396 trace!(?dep, url, ?path, "installing git submodule");
397 self.git.submodule_add(true, url, path)
398 }
399
400 fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result<String> {
401 let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) };
403
404 let mut is_branch = false;
405 if std::io::stdout().is_terminal() {
407 if tag.is_empty() {
408 tag = self.match_tag(&tag, path)?;
409 } else if let Some(branch) = self.match_branch(&tag, path)? {
410 trace!(?tag, ?branch, "selecting branch for given tag");
411 tag = branch;
412 is_branch = true;
413 }
414 }
415 let url = dep.url.as_ref().unwrap();
416
417 let res = self.git.root(path).checkout(recurse, &tag);
418 if let Err(mut e) = res {
419 fs::remove_dir_all(path)?;
421 if e.to_string().contains("did not match any file(s) known to git") {
422 e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!")
423 }
424 return Err(e);
425 }
426
427 if is_branch { Ok(tag) } else { Ok(String::new()) }
428 }
429
430 fn match_tag(self, tag: &str, path: &Path) -> Result<String> {
432 if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) {
434 return Ok(tag.into());
435 }
436
437 let trimmed_tag = tag.trim_start_matches('v').to_string();
441 let output = self.git.root(path).tag()?;
442 let mut candidates: Vec<String> = output
443 .trim()
444 .lines()
445 .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag))
446 .map(|x| x.to_string())
447 .rev()
448 .collect();
449
450 if candidates.is_empty() {
452 return Ok(tag.into());
453 }
454
455 for candidate in &candidates {
457 if candidate == tag {
458 return Ok(tag.into());
459 }
460 }
461
462 if candidates.len() == 1 {
464 let matched_tag = &candidates[0];
465 let input = prompt!(
466 "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] "
467 )?;
468 return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) };
469 }
470
471 candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG"));
473 sh_println!("There are multiple matching tags:")?;
474 for (i, candidate) in candidates.iter().enumerate() {
475 sh_println!("[{i}] {candidate}")?;
476 }
477
478 let n_candidates = candidates.len();
479 loop {
480 let input: String =
481 prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?;
482 let s = input.trim();
483 let n = if s.is_empty() { Ok(1) } else { s.parse() };
485 match n {
487 Ok(0) => return Ok(tag.into()),
488 Ok(i) if (1..=n_candidates).contains(&i) => {
489 let c = &candidates[i];
490 sh_println!("[{i}] {c} selected")?;
491 return Ok(c.clone());
492 }
493 _ => continue,
494 }
495 }
496 }
497
498 fn match_branch(self, tag: &str, path: &Path) -> Result<Option<String>> {
499 let output = self.git.root(path).cmd().args(["branch", "-r"]).get_stdout_lossy()?;
501
502 let mut candidates = output
503 .lines()
504 .map(|x| x.trim().trim_start_matches("origin/"))
505 .filter(|x| x.starts_with(tag))
506 .map(ToString::to_string)
507 .rev()
508 .collect::<Vec<_>>();
509
510 trace!(?candidates, ?tag, "found branch candidates");
511
512 if candidates.is_empty() {
514 return Ok(None);
515 }
516
517 for candidate in &candidates {
519 if candidate == tag {
520 return Ok(Some(tag.to_string()));
521 }
522 }
523
524 if candidates.len() == 1 {
526 let matched_tag = &candidates[0];
527 let input = prompt!(
528 "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] "
529 )?;
530 return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) };
531 }
532
533 candidates.insert(0, format!("{tag} (original branch)"));
535 sh_println!("There are multiple matching branches:")?;
536 for (i, candidate) in candidates.iter().enumerate() {
537 sh_println!("[{i}] {candidate}")?;
538 }
539
540 let n_candidates = candidates.len();
541 let input: String = prompt!(
542 "Please select a tag (0-{}, default: 1, Press <enter> to cancel): ",
543 n_candidates - 1
544 )?;
545 let input = input.trim();
546
547 if input.is_empty() {
549 sh_println!("Canceled branch matching")?;
550 return Ok(None);
551 }
552
553 match input.parse::<usize>() {
555 Ok(0) => Ok(Some(tag.into())),
556 Ok(i) if (1..=n_candidates).contains(&i) => {
557 let c = &candidates[i];
558 sh_println!("[{i}] {c} selected")?;
559 Ok(Some(c.clone()))
560 }
561 _ => Ok(None),
562 }
563 }
564}
565
566fn match_yn(input: String) -> bool {
570 let s = input.trim().to_lowercase();
571 matches!(s.as_str(), "" | "y" | "yes")
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use tempfile::tempdir;
578
579 #[test]
580 #[ignore = "slow"]
581 fn get_oz_tags() {
582 let tmp = tempdir().unwrap();
583 let git = Git::new(tmp.path());
584 let installer = Installer { git, commit: false };
585
586 git.init().unwrap();
587
588 let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap();
589 let libs = tmp.path().join("libs");
590 fs::create_dir(&libs).unwrap();
591 let submodule = libs.join("openzeppelin-contracts");
592 installer.git_submodule(&dep, &submodule).unwrap();
593 assert!(submodule.exists());
594
595 let tags = installer.git_semver_tags(&submodule).unwrap();
596 assert!(!tags.is_empty());
597 let v480: Version = "4.8.0".parse().unwrap();
598 assert!(tags.iter().any(|(_, v)| v == &v480));
599 }
600}