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