1use alloy_json_abi::JsonAbi;
2use alloy_primitives::{Address, U256, map::HashMap};
3use alloy_provider::{Network, Provider, RootProvider, network::AnyNetwork};
4use eyre::{ContextCompat, Result};
5use foundry_common::{provider::ProviderBuilder, shell};
6use foundry_config::{Chain, Config};
7use itertools::Itertools;
8use path_slash::PathExt;
9use regex::Regex;
10use serde::de::DeserializeOwned;
11use std::{
12 collections::{BTreeMap, BTreeSet},
13 ffi::{OsStr, OsString},
14 path::{Path, PathBuf},
15 process::{Command, Output, Stdio},
16 str::FromStr,
17 sync::{LazyLock, OnceLock},
18 time::{Duration, SystemTime, UNIX_EPOCH},
19};
20use tracing_subscriber::{EnvFilter, prelude::*, reload};
21
22mod cmd;
23pub use cmd::*;
24
25mod suggestions;
26pub use suggestions::*;
27
28mod abi;
29pub use abi::*;
30
31mod allocator;
32pub use allocator::*;
33
34mod tempo;
35pub use tempo::*;
36
37#[doc(hidden)]
39pub use foundry_config::utils::*;
40
41pub const STATIC_FUZZ_SEED: [u8; 32] = [
45 0x01, 0x00, 0xfa, 0x69, 0xa5, 0xf1, 0x71, 0x0a, 0x95, 0xcd, 0xef, 0x94, 0x88, 0x9b, 0x02, 0x84,
46 0x5d, 0x64, 0x0b, 0x19, 0xad, 0xf0, 0xe3, 0x57, 0xb8, 0xd4, 0xbe, 0x7d, 0x49, 0xee, 0x70, 0xe6,
47];
48
49pub fn apply_gas_estimate_multiplier(estimate: u64, multiplier: Option<u64>) -> Result<u64> {
51 let Some(multiplier) = multiplier else { return Ok(estimate) };
52 let adjusted = u128::from(estimate) * u128::from(multiplier) / 100;
53 adjusted.try_into().map_err(|_| eyre::eyre!("multiplied gas estimate exceeds u64"))
54}
55
56pub static SUBMODULE_BRANCH_REGEX: LazyLock<Regex> =
58 LazyLock::new(|| Regex::new(r#"\[submodule "([^"]+)"\](?:[^\[]*?branch = ([^\s]+))"#).unwrap());
59pub static SUBMODULE_STATUS_REGEX: LazyLock<Regex> =
61 LazyLock::new(|| Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$").unwrap());
62
63static FILTER_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
65 OnceLock::new();
66
67pub trait FoundryPathExt {
69 fn is_sol_test(&self) -> bool;
71
72 fn is_sol(&self) -> bool;
74
75 fn is_yul(&self) -> bool;
77}
78
79impl<T: AsRef<Path>> FoundryPathExt for T {
80 fn is_sol_test(&self) -> bool {
81 self.as_ref()
82 .file_name()
83 .and_then(|s| s.to_str())
84 .map(|s| s.ends_with(".t.sol"))
85 .unwrap_or_default()
86 }
87
88 fn is_sol(&self) -> bool {
89 self.as_ref().extension() == Some(std::ffi::OsStr::new("sol"))
90 }
91
92 fn is_yul(&self) -> bool {
93 self.as_ref().extension() == Some(std::ffi::OsStr::new("yul"))
94 }
95}
96
97pub fn subscriber() {
102 let (filter_layer, reload_handle) = reload::Layer::new(env_filter());
103 let registry = tracing_subscriber::Registry::default().with(filter_layer);
104 #[cfg(feature = "tracy")]
105 let registry = registry.with(tracing_tracy::TracyLayer::default());
106 registry.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)).init();
107 let _ = FILTER_RELOAD_HANDLE.set(reload_handle);
108}
109
110pub fn update_tracing_filter(directives: &str) {
115 let Some(handle) = FILTER_RELOAD_HANDLE.get() else {
116 return;
117 };
118 let Ok(new_filter) = directives.parse::<EnvFilter>() else {
119 return;
120 };
121 let _ = handle.reload(new_filter);
122}
123
124fn env_filter() -> EnvFilter {
125 const DEFAULT_DIRECTIVES: &[&str] = &include!("./default_directives.txt");
126 let mut filter = EnvFilter::from_default_env();
127 for &directive in DEFAULT_DIRECTIVES {
128 filter = filter.add_directive(directive.parse().unwrap());
129 }
130 filter
131}
132
133pub fn get_provider(config: &Config) -> Result<RootProvider<AnyNetwork>> {
135 get_provider_builder(config)?.build()
136}
137
138pub fn get_provider_builder(config: &Config) -> Result<ProviderBuilder> {
142 ProviderBuilder::from_config(config)
143}
144
145pub async fn get_chain<N, P>(chain: Option<Chain>, provider: P) -> Result<Chain>
146where
147 N: Network,
148 P: Provider<N>,
149{
150 match chain {
151 Some(chain) => Ok(chain),
152 None => Ok(Chain::from_id(provider.get_chain_id().await?)),
153 }
154}
155
156pub fn parse_ether_value(value: &str) -> Result<U256> {
163 Ok(if value.starts_with("0x") || value.starts_with("0X") {
164 U256::from_str(value)?
165 } else {
166 alloy_dyn_abi::DynSolType::coerce_str(&alloy_dyn_abi::DynSolType::Uint(256), value)?
167 .as_uint()
168 .wrap_err("Could not parse ether value from string")?
169 .0
170 })
171}
172
173pub fn parse_json<T: DeserializeOwned>(value: &str) -> serde_json::Result<T> {
175 serde_json::from_str(value)
176}
177
178pub fn parse_delay(delay: &str) -> Result<Duration> {
180 let delay = if delay.ends_with("ms") {
181 let d: u64 = delay.trim_end_matches("ms").parse()?;
182 Duration::from_millis(d)
183 } else {
184 let d: f64 = delay.parse()?;
185 let delay = (d * 1000.0).round();
186 if delay.is_infinite() || delay.is_nan() || delay.is_sign_negative() {
187 eyre::bail!("delay must be finite and non-negative");
188 }
189
190 Duration::from_millis(delay as u64)
191 };
192 Ok(delay)
193}
194
195pub fn now() -> Duration {
197 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
198}
199
200pub fn common_setup() -> Vec<String> {
202 install_crypto_provider();
203 crate::handler::install();
204 let warnings = load_dotenv();
205 enable_paint();
206 warnings
207}
208
209pub fn load_dotenv() -> Vec<String> {
211 let mut paths = Vec::new();
215 if let (Ok(cwd), Ok(project_root)) = (std::env::current_dir(), find_project_root(None)) {
216 let project_env = project_root.join(".env");
217 if project_env.is_file() {
218 paths.push(project_env);
219 }
220 if cwd != project_root {
221 let cwd_env = cwd.join(".env");
223 if cwd_env.is_file() {
224 paths.push(cwd_env);
225 }
226 }
227 }
228
229 let mut warnings = Vec::new();
230 for path in paths {
231 if let Err(err) = dotenvy::from_path(&path) {
232 let reason = match err {
234 dotenvy::Error::LineParse(..) => {
235 "invalid syntax; remaining variables were not loaded".to_string()
236 }
237 _ => err.to_string(),
238 };
239 warnings.push(format!("Failed to load {}: {}", path.display(), reason));
240 }
241 }
242 warnings
243}
244
245pub fn enable_paint() {
247 let enable = yansi::Condition::os_support() && yansi::Condition::tty_and_color_live();
248 yansi::whenever(yansi::Condition::cached(enable));
249}
250
251pub fn install_crypto_provider() {
262 rustls::crypto::ring::default_provider()
264 .install_default()
265 .expect("Failed to install default rustls crypto provider");
266}
267
268pub async fn fetch_abi_from_etherscan(
274 address: Address,
275 config: &foundry_config::Config,
276 follow_proxy: bool,
277) -> Result<Vec<(JsonAbi, String)>> {
278 let chain = config.chain.unwrap_or_default();
279 let client = config
280 .get_etherscan_config_with_chain(Some(chain))?
281 .ok_or_else(|| eyre::eyre!("No Etherscan API key configured for chain {chain}"))?
282 .into_client_with_no_proxy(config.eth_rpc_no_proxy)?;
283 let fetch_abis = async |address| -> Result<_> {
284 let source = client.contract_source_code(address).await?;
285 let implementation = source
286 .items
287 .first()
288 .filter(|item| item.proxy != 0)
289 .and_then(|item| item.implementation);
290 let abis = source
291 .abis()?
292 .into_iter()
293 .zip(source.items.into_iter().map(|item| item.contract_name))
294 .collect::<Vec<_>>();
295 Ok((abis, implementation))
296 };
297 let (mut abis, implementation) = fetch_abis(address).await?;
298 if follow_proxy && let Some(implementation) = implementation {
299 sh_status!(
300 "Contract at {address} is a proxy, fetching implementation at {implementation}..."
301 )?;
302 match fetch_abis(implementation).await {
303 Ok((implementation, _)) => abis.extend(implementation),
304 Err(err) => sh_warn!("Could not fetch implementation ABI: {err}")?,
305 }
306 }
307 Ok(abis)
308}
309
310pub trait CommandUtils {
312 fn exec(&mut self) -> Result<Output>;
314
315 fn get_stdout_lossy(&mut self) -> Result<String>;
317}
318
319impl CommandUtils for Command {
320 #[track_caller]
321 fn exec(&mut self) -> Result<Output> {
322 trace!(command=?self, "executing");
323
324 let output = self.output()?;
325
326 trace!(code=?output.status.code(), ?output);
327
328 if output.status.success() {
329 Ok(output)
330 } else {
331 let stdout = String::from_utf8_lossy(&output.stdout);
332 let stdout = stdout.trim();
333 let stderr = String::from_utf8_lossy(&output.stderr);
334 let stderr = stderr.trim();
335 let msg = if stdout.is_empty() {
336 stderr.to_string()
337 } else if stderr.is_empty() {
338 stdout.to_string()
339 } else {
340 format!("stdout:\n{stdout}\n\nstderr:\n{stderr}")
341 };
342
343 let mut name = self.get_program().to_string_lossy();
344 if let Some(arg) = self.get_args().next() {
345 let arg = arg.to_string_lossy();
346 if !arg.starts_with('-') {
347 let name = name.to_mut();
348 name.push(' ');
349 name.push_str(&arg);
350 }
351 }
352
353 let mut err = match output.status.code() {
354 Some(code) => format!("{name} exited with code {code}"),
355 None => format!("{name} terminated by a signal"),
356 };
357 if !msg.is_empty() {
358 err.push(':');
359 err.push(if msg.lines().count() == 1 { ' ' } else { '\n' });
360 err.push_str(&msg);
361 }
362 Err(eyre::eyre!(err))
363 }
364 }
365
366 #[track_caller]
367 fn get_stdout_lossy(&mut self) -> Result<String> {
368 let output = self.exec()?;
369 let stdout = String::from_utf8_lossy(&output.stdout);
370 Ok(stdout.trim().into())
371 }
372}
373
374#[derive(Clone, Copy, Debug)]
375pub struct Git<'a> {
376 pub root: &'a Path,
377 pub quiet: bool,
378 pub shallow: bool,
379}
380
381impl<'a> Git<'a> {
382 pub fn new(root: &'a Path) -> Self {
383 Self { root, quiet: shell::is_quiet(), shallow: false }
384 }
385
386 pub fn from_config(config: &'a Config) -> Self {
387 Self::new(config.root.as_path())
388 }
389
390 pub fn root_of(relative_to: &Path) -> Result<PathBuf> {
391 let output = Self::cmd_no_root()
392 .current_dir(relative_to)
393 .args(["rev-parse", "--show-toplevel"])
394 .get_stdout_lossy()?;
395 Ok(PathBuf::from(output))
396 }
397
398 pub fn clone(
399 shallow: bool,
400 from: impl AsRef<OsStr>,
401 to: Option<impl AsRef<OsStr>>,
402 ) -> Result<()> {
403 Self::cmd_no_root()
404 .stderr(Stdio::inherit())
405 .args(["clone", "--recurse-submodules"])
406 .args(shallow.then_some("--depth=1"))
407 .args(shallow.then_some("--shallow-submodules"))
408 .arg(from)
409 .args(to)
410 .exec()
411 .map(drop)
412 }
413
414 pub fn fetch(
415 self,
416 shallow: bool,
417 remote: impl AsRef<OsStr>,
418 branch: Option<impl AsRef<OsStr>>,
419 ) -> Result<()> {
420 self.cmd()
421 .stderr(Stdio::inherit())
422 .arg("fetch")
423 .args(shallow.then_some("--no-tags"))
424 .args(shallow.then_some("--depth=1"))
425 .arg(remote)
426 .args(branch)
427 .exec()
428 .map(drop)
429 }
430
431 pub const fn root(self, root: &Path) -> Git<'_> {
432 Git { root, ..self }
433 }
434
435 pub const fn quiet(self, quiet: bool) -> Self {
436 Self { quiet, ..self }
437 }
438
439 pub const fn shallow(self, shallow: bool) -> Self {
441 Self { shallow, ..self }
442 }
443
444 pub fn checkout(self, recursive: bool, tag: impl AsRef<OsStr>) -> Result<()> {
445 let tag = tag.as_ref();
446 validate_checkout_ref(tag)?;
447 self.cmd()
448 .arg("checkout")
449 .args(recursive.then_some("--recurse-submodules"))
450 .arg(tag)
451 .arg("--")
452 .exec()
453 .map(drop)
454 }
455
456 pub fn head(self) -> Result<String> {
458 self.cmd().args(["rev-parse", "HEAD"]).get_stdout_lossy()
459 }
460
461 pub fn checkout_at(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<()> {
462 let tag = tag.as_ref();
463 validate_checkout_ref(tag)?;
464 self.cmd_at(at).arg("checkout").arg(tag).arg("--").exec().map(drop)
465 }
466
467 pub fn init(self) -> Result<()> {
468 self.cmd().arg("init").exec().map(drop)
469 }
470
471 pub fn current_rev_branch(self, at: &Path) -> Result<(String, String)> {
472 let rev = self.cmd_at(at).args(["rev-parse", "HEAD"]).get_stdout_lossy()?;
473 let branch =
474 self.cmd_at(at).args(["rev-parse", "--abbrev-ref", "HEAD"]).get_stdout_lossy()?;
475 Ok((rev, branch))
476 }
477
478 #[expect(clippy::should_implement_trait)] pub fn add<I, S>(self, paths: I) -> Result<()>
480 where
481 I: IntoIterator<Item = S>,
482 S: AsRef<OsStr>,
483 {
484 self.cmd().arg("add").args(paths).exec().map(drop)
485 }
486
487 pub fn add_literal(self, path: &Path) -> Result<()> {
488 self.cmd().args(["--literal-pathspecs", "add", "--"]).arg(path).exec().map(drop)
489 }
490
491 pub fn reset(self, hard: bool, tree: impl AsRef<OsStr>) -> Result<()> {
492 self.cmd().arg("reset").args(hard.then_some("--hard")).arg(tree).exec().map(drop)
493 }
494
495 pub fn commit_tree(
496 self,
497 tree: impl AsRef<OsStr>,
498 msg: Option<impl AsRef<OsStr>>,
499 ) -> Result<String> {
500 self.cmd()
501 .arg("commit-tree")
502 .arg(tree)
503 .args(msg.as_ref().is_some().then_some("-m"))
504 .args(msg)
505 .get_stdout_lossy()
506 }
507
508 pub fn rm<I, S>(self, force: bool, paths: I) -> Result<()>
509 where
510 I: IntoIterator<Item = S>,
511 S: AsRef<OsStr>,
512 {
513 self.cmd().arg("rm").args(force.then_some("--force")).args(paths).exec().map(drop)
514 }
515
516 pub fn remove_index_path(self, path: &Path) -> Result<()> {
517 self.cmd()
518 .args(["--literal-pathspecs", "rm", "--cached", "--force", "--"])
519 .arg(path)
520 .exec()
521 .map(drop)
522 }
523
524 pub fn commit(self, msg: &str) -> Result<()> {
525 let output = self
526 .cmd()
527 .args(["commit", "-m", msg])
528 .args(cfg!(any(test, debug_assertions)).then_some("--no-gpg-sign"))
529 .output()?;
530 if !output.status.success() {
531 let stdout = String::from_utf8_lossy(&output.stdout);
532 let stderr = String::from_utf8_lossy(&output.stderr);
533 let msg = "nothing to commit, working tree clean";
535 if !(stdout.contains(msg) || stderr.contains(msg)) {
536 return Err(eyre::eyre!(
537 "failed to commit (code={:?}, stdout={:?}, stderr={:?})",
538 output.status.code(),
539 stdout.trim(),
540 stderr.trim()
541 ));
542 }
543 }
544 Ok(())
545 }
546
547 pub fn is_in_repo(self) -> std::io::Result<bool> {
548 self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success())
549 }
550
551 pub fn is_repo_root(self) -> Result<bool> {
552 self.cmd().args(["rev-parse", "--show-cdup"]).get_stdout_lossy().map(|s| s.is_empty())
553 }
554
555 pub fn is_clean(self) -> Result<bool> {
556 self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty())
557 }
558
559 pub fn is_path_clean(self, path: &Path) -> Result<bool> {
560 self.cmd()
561 .args(["--literal-pathspecs", "status", "--porcelain", "--"])
562 .arg(path)
563 .exec()
564 .map(|out| out.stdout.is_empty())
565 }
566
567 pub fn has_branch(self, branch: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
568 self.cmd_at(at)
569 .args(["branch", "--list", "--no-color"])
570 .arg(branch)
571 .get_stdout_lossy()
572 .map(|stdout| !stdout.is_empty())
573 }
574
575 pub fn has_tag(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
576 self.cmd_at(at)
577 .args(["tag", "--list"])
578 .arg(tag)
579 .get_stdout_lossy()
580 .map(|stdout| !stdout.is_empty())
581 }
582
583 pub fn has_rev(self, rev: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
584 self.cmd_at(at)
585 .args(["cat-file", "-t"])
586 .arg(rev)
587 .get_stdout_lossy()
588 .map(|stdout| &stdout == "commit")
589 }
590
591 pub fn get_rev(self, tag_or_branch: impl AsRef<OsStr>, at: &Path) -> Result<String> {
592 self.cmd_at(at).args(["rev-list", "-n", "1"]).arg(tag_or_branch).get_stdout_lossy()
593 }
594
595 pub fn ensure_clean(self) -> Result<()> {
596 if self.is_clean()? {
597 Ok(())
598 } else {
599 Err(eyre::eyre!(
600 "\
601The target directory is a part of or on its own an already initialized git repository,
602and it requires clean working and staging areas, including no untracked files.
603
604Check the current git repository's status with `git status`.
605Then, you can track files with `git add ...` and then commit them with `git commit`,
606ignore them in the `.gitignore` file."
607 ))
608 }
609 }
610
611 pub fn commit_hash(self, short: bool, revision: &str) -> Result<String> {
612 self.cmd()
613 .arg("rev-parse")
614 .args(short.then_some("--short"))
615 .arg(revision)
616 .get_stdout_lossy()
617 }
618
619 pub fn tag(self) -> Result<String> {
620 self.cmd().arg("tag").get_stdout_lossy()
621 }
622
623 pub fn tag_for_commit(self, rev: &str, at: &Path) -> Result<Option<String>> {
631 self.cmd_at(at)
632 .args(["tag", "--contains"])
633 .arg(rev)
634 .get_stdout_lossy()
635 .map(|stdout| stdout.lines().next().map(str::to_string))
636 }
637
638 pub fn read_submodules_with_branch(
646 self,
647 at: &Path,
648 lib: &OsStr,
649 ) -> Result<HashMap<PathBuf, String>> {
650 let gitmodules = foundry_common::fs::read_to_string(at.join(".gitmodules"))?;
652
653 let paths = SUBMODULE_BRANCH_REGEX
654 .captures_iter(&gitmodules)
655 .map(|cap| {
656 let path_str = cap.get(1).unwrap().as_str();
657 let path = PathBuf::from_str(path_str).unwrap();
658 trace!(path = %path.display(), "unstripped path");
659
660 let lib_pos = path.components().find_position(|c| c.as_os_str() == lib);
667 let path = path
668 .components()
669 .skip(lib_pos.map(|(i, _)| i).unwrap_or(0))
670 .collect::<PathBuf>();
671
672 let branch = cap.get(2).unwrap().as_str().to_string();
673 (path, branch)
674 })
675 .collect::<HashMap<_, _>>();
676
677 Ok(paths)
678 }
679
680 pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
681 where
682 I: IntoIterator<Item = S>,
683 S: AsRef<OsStr>,
684 {
685 let paths = paths.into_iter().map(|path| path.as_ref().to_owned()).collect::<Vec<_>>();
686 if self.submodules_initialized(&paths).unwrap_or(false) {
687 return Ok(false);
688 }
689
690 self.cmd()
691 .args(["submodule", "status"])
692 .args(&paths)
693 .get_stdout_lossy()
694 .map(|stdout| stdout.lines().any(|line| line.starts_with('-')))
695 }
696
697 fn submodules_initialized(self, paths: &[OsString]) -> Result<bool> {
699 if !self.root.is_absolute() {
701 return Ok(false);
702 }
703 let Some(root) = find_git_root(self.root)? else {
704 return Ok(true);
705 };
706 let root = root.as_path();
707 if paths.iter().any(|path| {
708 Path::new(path)
709 .components()
710 .any(|component| matches!(component, std::path::Component::ParentDir))
711 }) {
712 return Ok(false);
713 }
714 let relative_root = self.root.strip_prefix(root).unwrap_or_else(|_| Path::new(""));
715 let gitmodules = root.join(".gitmodules");
716 if !gitmodules.is_file() {
717 return Ok(false);
720 }
721
722 let output = Command::new("git")
723 .args(["config", "--null", "--file"])
724 .arg(gitmodules)
725 .args(["--get-regexp", r"^submodule\..*\.path$"])
726 .get_stdout_lossy()?;
727
728 for entry in output.split_terminator('\0') {
729 let (_, path) = entry
730 .split_once('\n')
731 .ok_or_else(|| eyre::eyre!("invalid submodule path config"))?;
732 let path = Path::new(path);
733 let matches = paths.is_empty()
734 || paths.iter().any(|prefix| {
735 let prefix = Path::new(prefix);
736 if prefix.is_absolute() {
737 prefix.strip_prefix(root).is_ok_and(|prefix| path.starts_with(prefix))
738 } else {
739 path.starts_with(relative_root.join(prefix))
740 }
741 });
742 if matches && !root.join(path).join(".git").exists() {
743 return Ok(false);
744 }
745 }
746 Ok(true)
747 }
748
749 pub fn has_submodules<I, S>(self, paths: I) -> Result<bool>
751 where
752 I: IntoIterator<Item = S>,
753 S: AsRef<OsStr>,
754 {
755 self.cmd()
756 .args(["submodule", "status"])
757 .args(paths)
758 .get_stdout_lossy()
759 .map(|stdout| stdout.trim().lines().next().is_some())
760 }
761
762 pub fn submodule_add(
763 self,
764 force: bool,
765 url: impl AsRef<OsStr>,
766 path: impl AsRef<OsStr>,
767 ) -> Result<()> {
768 self.cmd()
769 .arg("--literal-pathspecs")
770 .stderr(self.stderr())
771 .args(["submodule", "add"])
772 .args(self.shallow.then_some("--depth=1"))
773 .args(force.then_some("--force"))
774 .arg(url)
775 .arg(path)
776 .exec()
777 .map(drop)
778 }
779
780 pub fn submodule_update<I, S>(
781 self,
782 force: bool,
783 remote: bool,
784 no_fetch: bool,
785 recursive: bool,
786 paths: I,
787 ) -> Result<()>
788 where
789 I: IntoIterator<Item = S>,
790 S: AsRef<OsStr>,
791 {
792 self.cmd()
793 .arg("--literal-pathspecs")
794 .stderr(self.stderr())
795 .args(["submodule", "update", "--progress", "--init"])
796 .args(self.shallow.then_some("--depth=1"))
797 .args(force.then_some("--force"))
798 .args(remote.then_some("--remote"))
799 .args(no_fetch.then_some("--no-fetch"))
800 .args(recursive.then_some("--recursive"))
801 .arg("--")
802 .args(paths)
803 .exec()
804 .map(drop)
805 }
806
807 pub fn submodule_foreach(self, recursive: bool, cmd: impl AsRef<OsStr>) -> Result<()> {
808 self.cmd()
809 .stderr(self.stderr())
810 .args(["submodule", "foreach"])
811 .args(recursive.then_some("--recursive"))
812 .arg(cmd)
813 .exec()
814 .map(drop)
815 }
816
817 pub fn submodules_uninitialized(self) -> Result<bool> {
821 self.has_missing_dependencies(std::iter::empty::<&OsStr>())
824 }
825
826 pub fn submodule_init(self) -> Result<()> {
828 self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop)
829 }
830
831 pub fn submodules(&self) -> Result<Submodules> {
832 self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())?
833 }
834
835 pub fn submodules_in(&self, path: &Path) -> Result<Vec<SubmoduleCheckout>> {
837 self.submodules_in_worktree(path, self.root, Path::new(""))
838 }
839
840 pub fn submodules_in_worktree(
843 &self,
844 path: &Path,
845 worktree_root: &Path,
846 worktree_prefix: &Path,
847 ) -> Result<Vec<SubmoduleCheckout>> {
848 let pathspec = if path.as_os_str().is_empty() { Path::new(".") } else { path };
849 let output = self
850 .cmd()
851 .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"])
852 .arg(pathspec)
853 .exec()?;
854 let (_, mappings) = self.submodule_mappings_at(worktree_root)?;
855 let mut gitlinks = BTreeMap::new();
856 for entry in output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) {
857 let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else {
858 return Err(eyre::eyre!("invalid index entry"));
859 };
860 let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace();
861 let Some(mode) = fields.next() else {
862 return Err(eyre::eyre!("invalid index entry"));
863 };
864 let Some(rev) = fields.next() else {
865 return Err(eyre::eyre!("invalid index entry"));
866 };
867 let Some(stage) = fields.next() else {
868 return Err(eyre::eyre!("invalid index entry"));
869 };
870 if fields.next().is_some() {
871 return Err(eyre::eyre!("invalid index entry"));
872 }
873 if mode != "160000" {
874 continue;
875 }
876
877 let submodule_path = PathBuf::from(std::str::from_utf8(&entry[separator + 1..])?);
878 let worktree_path =
879 foundry_common::fs::normalize_path(&worktree_prefix.join(&submodule_path));
880 if !mappings.contains(&worktree_path) {
881 gitlinks.insert(
882 submodule_path.clone(),
883 SubmoduleCheckout {
884 status: SubmoduleCheckoutStatus::MissingMapping,
885 rev: rev.to_string(),
886 path: submodule_path,
887 },
888 );
889 continue;
890 }
891 if stage != "0" {
892 gitlinks.insert(
893 submodule_path.clone(),
894 SubmoduleCheckout {
895 status: SubmoduleCheckoutStatus::Conflicted,
896 rev: rev.to_string(),
897 path: submodule_path,
898 },
899 );
900 continue;
901 }
902
903 let status = self
904 .cmd()
905 .args(["--literal-pathspecs", "submodule", "status", "--"])
906 .arg(&submodule_path)
907 .get_stdout_lossy()?;
908 let (status, rev) = match status.as_bytes().first() {
909 Some(b'-') => (SubmoduleCheckoutStatus::Uninitialized, &status[1..]),
910 Some(b'+') => (SubmoduleCheckoutStatus::Modified, &status[1..]),
911 Some(b'U') => (SubmoduleCheckoutStatus::Conflicted, &status[1..]),
912 Some(_) => (SubmoduleCheckoutStatus::Current, status.as_str()),
913 None => return Err(eyre::eyre!("missing submodule status")),
914 };
915 let rev = rev
916 .split_ascii_whitespace()
917 .next()
918 .ok_or_else(|| eyre::eyre!("invalid submodule status"))?;
919 gitlinks.insert(
920 submodule_path.clone(),
921 SubmoduleCheckout { status, rev: rev.to_string(), path: submodule_path },
922 );
923 }
924 Ok(gitlinks.into_values().collect())
925 }
926
927 pub fn submodule_sync(self) -> Result<()> {
928 self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop)
929 }
930
931 pub fn submodule_url(self, path: &Path) -> Result<Option<String>> {
933 self.cmd()
934 .args(["config", "--get", &format!("submodule.{}.url", path.to_slash_lossy())])
935 .get_stdout_lossy()
936 .map(|url| Some(url.trim().to_string()))
937 }
938
939 pub fn has_submodule_mapping(self, path: &Path) -> Result<bool> {
941 let (names, paths) = self.submodule_mappings()?;
942 Ok(names.contains(path) || paths.contains(path))
943 }
944
945 fn submodule_mappings(self) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
946 self.submodule_mappings_at(self.root)
947 }
948
949 fn submodule_mappings_at(self, root: &Path) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
950 let gitmodules = root.join(".gitmodules");
951 if !gitmodules.exists() {
952 return Ok(Default::default());
953 }
954
955 let output = self
956 .cmd()
957 .args(["config", "--null", "--file"])
958 .arg(gitmodules)
959 .args(["--get-regexp", r"^submodule\..*"])
960 .output()?;
961 match output.status.code() {
962 Some(0) => {
963 let mut names = BTreeSet::new();
964 let mut paths = BTreeSet::new();
965 for entry in
966 output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty())
967 {
968 let Some(separator) = entry.iter().position(|byte| *byte == b'\n') else {
969 return Err(eyre::eyre!("invalid submodule mapping entry"));
970 };
971 let key = std::str::from_utf8(&entry[..separator])?;
972 let value = std::str::from_utf8(&entry[separator + 1..])?;
973 let Some(key) = key.strip_prefix("submodule.") else { continue };
974 let Some((name, field)) = key.rsplit_once('.') else { continue };
975 names.insert(PathBuf::from(name));
976 if field == "path" {
977 paths.insert(PathBuf::from(value));
978 }
979 }
980 Ok((names, paths))
981 }
982 Some(1) => Ok(Default::default()),
983 _ => Err(eyre::eyre!(
984 "failed to inspect .gitmodules: {}",
985 String::from_utf8_lossy(&output.stderr).trim()
986 )),
987 }
988 }
989
990 pub fn is_gitlink(self, path: &Path) -> Result<bool> {
992 self.cmd().args(["ls-files", "--stage", "-z", "--"]).arg(path).exec().map(|output| {
993 let expected_path = path.to_slash_lossy();
994 output.stdout.split(|byte| *byte == 0).any(|entry| {
995 entry.starts_with(b"160000 ")
996 && entry
997 .iter()
998 .position(|byte| *byte == b'\t')
999 .and_then(|separator| entry.get(separator + 1..))
1000 == Some(expected_path.as_bytes())
1001 })
1002 })
1003 }
1004
1005 pub fn has_index_entries(self, path: &Path) -> Result<bool> {
1007 self.cmd()
1008 .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"])
1009 .arg(path)
1010 .exec()
1011 .map(|output| !output.stdout.is_empty())
1012 }
1013
1014 pub fn is_normal_tracked_file(self, path: &Path) -> Result<bool> {
1016 let output = self
1017 .cmd()
1018 .args(["--literal-pathspecs", "ls-files", "--stage", "-v", "-z", "--"])
1019 .arg(path)
1020 .exec()?;
1021 let mut entries = output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty());
1022 let Some(entry) = entries.next() else { return Ok(false) };
1023 if entries.next().is_some() {
1024 return Ok(false);
1025 }
1026 let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else {
1027 return Err(eyre::eyre!("invalid index entry"));
1028 };
1029 if entry.get(separator + 1..) != Some(path.to_slash_lossy().as_bytes()) {
1030 return Ok(false);
1031 }
1032 let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace();
1033 if fields.next() != Some("H") || !matches!(fields.next(), Some("100644" | "100755")) {
1034 return Ok(false);
1035 }
1036 let Some(index_hash) = fields.next() else { return Ok(false) };
1037 if fields.next() != Some("0") || fields.next().is_some() {
1038 return Ok(false);
1039 }
1040 let worktree_hash = self.cmd().args(["hash-object", "--"]).arg(path).get_stdout_lossy()?;
1041 Ok(worktree_hash.trim() == index_hash)
1042 }
1043
1044 pub fn has_submodule_config(self, path: &Path) -> Result<bool> {
1046 let pattern = format!(r"^submodule\.{}\.", regex::escape(&path.to_slash_lossy()));
1047 let output = self.cmd().args(["config", "--local", "--get-regexp", &pattern]).output()?;
1048 match output.status.code() {
1049 Some(0) => Ok(true),
1050 Some(1) => Ok(false),
1051 _ => Err(eyre::eyre!(
1052 "failed to inspect submodule config: {}",
1053 String::from_utf8_lossy(&output.stderr).trim()
1054 )),
1055 }
1056 }
1057
1058 pub fn remove_submodule_config(self, path: &Path) -> Result<()> {
1060 if self.has_submodule_config(path)? {
1061 let section = format!("submodule.{}", path.to_slash_lossy());
1062 self.cmd().args(["config", "--local", "--remove-section", §ion]).exec()?;
1063 }
1064 Ok(())
1065 }
1066
1067 pub fn absolute_git_dir(self) -> Result<PathBuf> {
1069 self.cmd().args(["rev-parse", "--absolute-git-dir"]).get_stdout_lossy().map(PathBuf::from)
1070 }
1071
1072 pub fn remote_url(self, name: &str) -> Option<String> {
1074 self.cmd().args(["remote", "get-url", name]).get_stdout_lossy().ok()
1075 }
1076
1077 pub fn set_submodule_branch(self, rel_path: &Path, branch: &str) -> Result<()> {
1079 self.cmd().args(["submodule", "set-branch", "-b", branch]).arg(rel_path).exec().map(drop)
1080 }
1081
1082 pub fn remote_branches(self) -> Result<String> {
1084 self.cmd().args(["branch", "-r"]).get_stdout_lossy()
1085 }
1086
1087 pub fn fetch_and_checkout_branch(self, at: &Path, branch: &str) -> Result<()> {
1089 self.cmd_at(at).args(["fetch", "--", "origin", branch]).exec().map_err(|e| {
1090 eyre::eyre!(
1091 "Could not fetch latest changes for branch {branch} in submodule at {}: {e}",
1092 at.display()
1093 )
1094 })?;
1095 self.cmd_at(at)
1096 .args(["checkout", "-B", branch, &format!("origin/{branch}")])
1097 .exec()
1098 .map_err(|e| {
1099 eyre::eyre!(
1100 "Could not checkout and track origin/{branch} for submodule at {}: {e}",
1101 at.display()
1102 )
1103 })?;
1104 Ok(())
1105 }
1106
1107 fn cmd(self) -> Command {
1108 let mut cmd = Self::cmd_no_root();
1109 cmd.current_dir(self.root);
1110 cmd
1111 }
1112
1113 fn cmd_at(self, path: &Path) -> Command {
1114 let mut cmd = Self::cmd_no_root();
1115 cmd.current_dir(path);
1116 cmd
1117 }
1118
1119 fn cmd_no_root() -> Command {
1120 let mut cmd = Command::new("git");
1121 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
1122 cmd
1123 }
1124
1125 fn stderr(self) -> Stdio {
1127 if self.quiet { Stdio::piped() } else { Stdio::inherit() }
1128 }
1129}
1130
1131fn validate_checkout_ref(reference: &OsStr) -> Result<()> {
1132 if reference.is_empty() || reference.as_encoded_bytes().starts_with(b"-") {
1133 eyre::bail!("Git checkout reference must not be empty or start with `-`");
1134 }
1135 Ok(())
1136}
1137
1138#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1140pub struct Submodule {
1141 rev: String,
1143 path: PathBuf,
1145}
1146
1147impl Submodule {
1148 pub const fn new(rev: String, path: PathBuf) -> Self {
1149 Self { rev, path }
1150 }
1151
1152 pub fn rev(&self) -> &str {
1153 &self.rev
1154 }
1155
1156 pub const fn path(&self) -> &PathBuf {
1157 &self.path
1158 }
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Eq)]
1163pub struct SubmoduleCheckout {
1164 status: SubmoduleCheckoutStatus,
1165 rev: String,
1166 path: PathBuf,
1167}
1168
1169impl SubmoduleCheckout {
1170 pub const fn status(&self) -> SubmoduleCheckoutStatus {
1171 self.status
1172 }
1173
1174 pub fn rev(&self) -> &str {
1175 &self.rev
1176 }
1177
1178 pub const fn path(&self) -> &PathBuf {
1179 &self.path
1180 }
1181}
1182
1183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1185pub enum SubmoduleCheckoutStatus {
1186 Current,
1188 Uninitialized,
1190 Modified,
1192 Conflicted,
1194 MissingMapping,
1196}
1197
1198impl FromStr for Submodule {
1199 type Err = eyre::Report;
1200
1201 fn from_str(s: &str) -> Result<Self> {
1202 let caps = SUBMODULE_STATUS_REGEX
1203 .captures(s)
1204 .ok_or_else(|| eyre::eyre!("Invalid submodule status format"))?;
1205
1206 Ok(Self {
1207 rev: caps.get(1).unwrap().as_str().to_string(),
1208 path: PathBuf::from(caps.get(2).unwrap().as_str()),
1209 })
1210 }
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq)]
1215pub struct Submodules(pub Vec<Submodule>);
1216
1217impl Submodules {
1218 pub const fn len(&self) -> usize {
1219 self.0.len()
1220 }
1221
1222 pub const fn is_empty(&self) -> bool {
1223 self.0.is_empty()
1224 }
1225}
1226
1227impl FromStr for Submodules {
1228 type Err = eyre::Report;
1229
1230 fn from_str(s: &str) -> Result<Self> {
1231 let subs = s.lines().map(str::parse).collect::<Result<Vec<Submodule>>>()?;
1232 Ok(Self(subs))
1233 }
1234}
1235
1236impl<'a> IntoIterator for &'a Submodules {
1237 type Item = &'a Submodule;
1238 type IntoIter = std::slice::Iter<'a, Submodule>;
1239
1240 fn into_iter(self) -> Self::IntoIter {
1241 self.0.iter()
1242 }
1243}
1244#[cfg(test)]
1245mod tests {
1246 use super::*;
1247 use foundry_common::fs;
1248 use std::{env, fs::File, io::Write};
1249 use tempfile::tempdir;
1250
1251 #[cfg(unix)]
1252 use std::os::unix::fs::PermissionsExt;
1253
1254 #[test]
1255 fn applies_gas_estimate_multiplier() {
1256 assert_eq!(apply_gas_estimate_multiplier(21_000, None).unwrap(), 21_000);
1257 assert_eq!(apply_gas_estimate_multiplier(21_000, Some(150)).unwrap(), 31_500);
1258 assert_eq!(
1259 apply_gas_estimate_multiplier(21_000, Some(1_000_000_000_000_000)).unwrap(),
1260 210_000_000_000_000_000
1261 );
1262 assert!(apply_gas_estimate_multiplier(u64::MAX, Some(101)).is_err());
1263 }
1264
1265 #[test]
1266 fn checkout_does_not_parse_revision_as_option() {
1267 let tmp = tempdir().unwrap();
1268 let git = Git::new(tmp.path());
1269 git.init().unwrap();
1270 fs::write(tmp.path().join("file"), "clean\n").unwrap();
1271 git.add(["file"]).unwrap();
1272 git.commit("initial").unwrap();
1273 fs::write(tmp.path().join("file"), "dirty\n").unwrap();
1274
1275 assert!(git.checkout_at("-f", tmp.path()).is_err());
1276 assert_eq!(fs::read_to_string(tmp.path().join("file")).unwrap(), "dirty\n");
1277 }
1278
1279 #[cfg(unix)]
1280 #[test]
1281 fn fetch_does_not_parse_branch_as_option() {
1282 let remote = tempdir().unwrap();
1283 let remote_git = Git::new(remote.path());
1284 remote_git.init().unwrap();
1285 fs::write(remote.path().join("file"), "content\n").unwrap();
1286 remote_git.add(["file"]).unwrap();
1287 remote_git.commit("initial").unwrap();
1288
1289 let tmp = tempdir().unwrap();
1290 Git::clone(false, remote.path(), Some(tmp.path())).unwrap();
1291
1292 let marker = tmp.path().join("upload-pack-ran");
1293 let upload_pack = tmp.path().join("upload-pack");
1294 fs::write(
1295 &upload_pack,
1296 format!("#!/bin/sh\ntouch '{}'\nexec git-upload-pack \"$@\"\n", marker.display()),
1297 )
1298 .unwrap();
1299 let mut permissions = std::fs::metadata(&upload_pack).unwrap().permissions();
1300 permissions.set_mode(0o755);
1301 std::fs::set_permissions(&upload_pack, permissions).unwrap();
1302
1303 let branch = format!("--upload-pack={}", upload_pack.display());
1304 assert!(Git::new(tmp.path()).fetch_and_checkout_branch(tmp.path(), &branch).is_err());
1305 assert!(!marker.exists());
1306 }
1307
1308 #[test]
1309 fn parse_submodule_status() {
1310 let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)";
1311 let sub = Submodule::from_str(s).unwrap();
1312 assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1313 assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1314
1315 let s = "-8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1316 let sub = Submodule::from_str(s).unwrap();
1317 assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1318 assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1319
1320 let s = "8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts";
1321 let sub = Submodule::from_str(s).unwrap();
1322 assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1323 assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts"));
1324 }
1325
1326 #[test]
1327 fn parse_multiline_submodule_status() {
1328 let s = r#"+d3db4ef90a72b7d24aa5a2e5c649593eaef7801d lib/forge-std (v1.9.4-6-gd3db4ef)
1329+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)
1330"#;
1331 let subs = Submodules::from_str(s).unwrap().0;
1332 assert_eq!(subs.len(), 2);
1333 assert_eq!(subs[0].rev(), "d3db4ef90a72b7d24aa5a2e5c649593eaef7801d");
1334 assert_eq!(subs[0].path(), Path::new("lib/forge-std"));
1335 assert_eq!(subs[1].rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1336 assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts"));
1337 }
1338
1339 #[test]
1340 fn deserialize_submodule() {
1341 let submodule: Submodule =
1342 parse_json(r#"{"rev":"8829465a08cac423dcf59852f21e448449c1a1a8","path":"lib/dep"}"#)
1343 .unwrap();
1344 assert_eq!(submodule.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8");
1345 assert_eq!(submodule.path(), Path::new("lib/dep"));
1346 assert_eq!(
1347 serde_json::to_value(submodule).unwrap(),
1348 serde_json::json!({
1349 "rev": "8829465a08cac423dcf59852f21e448449c1a1a8",
1350 "path": "lib/dep",
1351 })
1352 );
1353 }
1354
1355 #[test]
1356 fn skips_submodule_status_if_dependencies_are_initialized() {
1357 let tmp = tempdir().unwrap();
1358 let root = tmp.path();
1359 std::fs::create_dir(root.join(".git")).unwrap();
1360 std::fs::write(
1361 root.join(".gitmodules"),
1362 r#"[submodule "lib/forge-std"]
1363 path = lib/forge-std
1364 url = https://github.com/foundry-rs/forge-std
1365"#,
1366 )
1367 .unwrap();
1368 std::fs::create_dir_all(root.join("lib/forge-std/.git")).unwrap();
1369
1370 let git = Git::new(root);
1371 assert!(git.submodules_initialized(&["lib".into()]).unwrap());
1372 assert!(git.submodules_initialized(&[root.join("lib").into()]).unwrap());
1373
1374 let nested = root.join("packages/contracts");
1375 std::fs::create_dir_all(&nested).unwrap();
1376 assert!(!Git::new(&nested).submodules_initialized(&["../../lib".into()]).unwrap());
1377
1378 assert!(!git.has_missing_dependencies(["lib"]).unwrap());
1380
1381 std::fs::remove_dir(root.join("lib/forge-std/.git")).unwrap();
1382 assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1383 }
1384
1385 #[test]
1386 fn foundry_path_ext_works() {
1387 let p = Path::new("contracts/MyTest.t.sol");
1388 assert!(p.is_sol_test());
1389 assert!(p.is_sol());
1390 let p = Path::new("contracts/Greeter.sol");
1391 assert!(!p.is_sol_test());
1392 }
1393
1394 #[test]
1395 fn parse_ether_value_accepts_hex_prefixed_wei() {
1396 assert_eq!(parse_ether_value("0x10").unwrap(), U256::from(16));
1397 assert_eq!(parse_ether_value("0X10").unwrap(), U256::from(16));
1398 assert_eq!(parse_ether_value("0x12").unwrap(), U256::from(0x12));
1399 assert_eq!(parse_ether_value("0xff").unwrap(), U256::from(0xff));
1400 assert_eq!(parse_ether_value("100").unwrap(), U256::from(100));
1401 assert_eq!(parse_ether_value("1ether").unwrap(), U256::from(1000000000000000000u128));
1402 }
1403
1404 #[test]
1406 fn can_load_dotenv() {
1407 let temp = tempdir().unwrap();
1408 Git::new(temp.path()).init().unwrap();
1409 let cwd_env = temp.path().join(".env");
1410 fs::create_file(temp.path().join("foundry.toml")).unwrap();
1411 let nested = temp.path().join("nested");
1412 fs::create_dir(&nested).unwrap();
1413
1414 let mut cwd_file = File::create(cwd_env).unwrap();
1415 let mut prj_file = File::create(nested.join(".env")).unwrap();
1416
1417 cwd_file.write_all(b"TESTCWDKEY=cwd_val\nBROWSER=./project-browser").unwrap();
1418 cwd_file.sync_all().unwrap();
1419
1420 prj_file.write_all(b"TESTPRJKEY=prj_val\nBROWSER=./nested-browser").unwrap();
1421 prj_file.sync_all().unwrap();
1422
1423 let browser = env::var_os("BROWSER");
1424 unsafe { env::remove_var("BROWSER") };
1425 let cwd = env::current_dir().unwrap();
1426 env::set_current_dir(&nested).unwrap();
1427 load_dotenv();
1428 env::set_current_dir(cwd).unwrap();
1429
1430 let loaded_browser = env::var_os("BROWSER");
1431 unsafe {
1432 if let Some(browser) = browser {
1433 env::set_var("BROWSER", browser);
1434 } else {
1435 env::remove_var("BROWSER");
1436 }
1437 }
1438
1439 assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val");
1440 assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val");
1441 assert_eq!(loaded_browser.as_deref(), Some(OsStr::new("./project-browser")));
1442 }
1443
1444 #[test]
1445 fn test_read_gitmodules_regex() {
1446 let gitmodules = r#"
1447 [submodule "lib/solady"]
1448 path = lib/solady
1449 url = ""
1450 branch = v0.1.0
1451 [submodule "lib/openzeppelin-contracts"]
1452 path = lib/openzeppelin-contracts
1453 url = ""
1454 branch = v4.8.0-791-g8829465a
1455 [submodule "lib/forge-std"]
1456 path = lib/forge-std
1457 url = ""
1458"#;
1459
1460 let paths = SUBMODULE_BRANCH_REGEX
1461 .captures_iter(gitmodules)
1462 .map(|cap| {
1463 (
1464 PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1465 String::from(cap.get(2).unwrap().as_str()),
1466 )
1467 })
1468 .collect::<HashMap<_, _>>();
1469
1470 assert_eq!(paths.get(Path::new("lib/solady")).unwrap(), "v0.1.0");
1471 assert_eq!(
1472 paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1473 "v4.8.0-791-g8829465a"
1474 );
1475
1476 let no_branch_gitmodules = r#"
1477 [submodule "lib/solady"]
1478 path = lib/solady
1479 url = ""
1480 [submodule "lib/openzeppelin-contracts"]
1481 path = lib/openzeppelin-contracts
1482 url = ""
1483 [submodule "lib/forge-std"]
1484 path = lib/forge-std
1485 url = ""
1486"#;
1487 let paths = SUBMODULE_BRANCH_REGEX
1488 .captures_iter(no_branch_gitmodules)
1489 .map(|cap| {
1490 (
1491 PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1492 String::from(cap.get(2).unwrap().as_str()),
1493 )
1494 })
1495 .collect::<HashMap<_, _>>();
1496
1497 assert!(paths.is_empty());
1498
1499 let branch_in_between = r#"
1500 [submodule "lib/solady"]
1501 path = lib/solady
1502 url = ""
1503 [submodule "lib/openzeppelin-contracts"]
1504 path = lib/openzeppelin-contracts
1505 url = ""
1506 branch = v4.8.0-791-g8829465a
1507 [submodule "lib/forge-std"]
1508 path = lib/forge-std
1509 url = ""
1510 "#;
1511
1512 let paths = SUBMODULE_BRANCH_REGEX
1513 .captures_iter(branch_in_between)
1514 .map(|cap| {
1515 (
1516 PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(),
1517 String::from(cap.get(2).unwrap().as_str()),
1518 )
1519 })
1520 .collect::<HashMap<_, _>>();
1521
1522 assert_eq!(paths.len(), 1);
1523 assert_eq!(
1524 paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(),
1525 "v4.8.0-791-g8829465a"
1526 );
1527 }
1528
1529 #[test]
1530 fn skips_submodule_status_outside_repository() {
1531 let tmp = tempdir().unwrap();
1532 let root = tmp.path().join("nested/project");
1533 fs::create_dir_all(&root).unwrap();
1534 let git = Git::new(&root);
1535 assert!(git.submodules_initialized(&["lib".into()]).unwrap());
1536 assert!(!git.has_missing_dependencies(["lib"]).unwrap());
1537 }
1538
1539 #[test]
1540 fn keeps_submodule_status_without_gitmodules() {
1541 let tmp = tempdir().unwrap();
1542 let git = Git::new(tmp.path());
1543 git.init().unwrap();
1544 fs::write(tmp.path().join("tracked"), "tracked file").unwrap();
1545 git.add(["tracked"]).unwrap();
1546
1547 assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1548 assert!(git.has_missing_dependencies(["lib"]).is_err());
1549
1550 let nested = tmp.path().join("packages/contracts");
1551 fs::create_dir_all(&nested).unwrap();
1552 let git = git.root(&nested);
1553 assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1554 assert!(git.has_missing_dependencies(["lib"]).is_err());
1555 }
1556
1557 #[test]
1558 fn detects_missing_dependencies_with_deleted_gitmodules() {
1559 let tmp = tempdir().unwrap();
1560 let git = Git::new(tmp.path());
1561 git.init().unwrap();
1562 let gitmodules = tmp.path().join(".gitmodules");
1563 fs::write(&gitmodules, "[submodule \"lib/dep\"]\n\tpath = lib/dep\n\turl = ../dep\n")
1564 .unwrap();
1565 git.add([".gitmodules"]).unwrap();
1566 git.cmd()
1567 .args([
1568 "update-index",
1569 "--add",
1570 "--cacheinfo",
1571 "160000,1111111111111111111111111111111111111111,lib/dep",
1572 ])
1573 .exec()
1574 .unwrap();
1575 assert!(git.has_missing_dependencies(["lib"]).unwrap());
1576
1577 fs::remove_file(gitmodules).unwrap();
1578 assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1579 assert!(git.has_missing_dependencies(["lib"]).unwrap());
1580
1581 let nested = tmp.path().join("nested");
1582 fs::create_dir(&nested).unwrap();
1583 assert!(!git.root(&nested).submodules_initialized(&[]).unwrap());
1584 assert!(git.root(&nested).submodules_uninitialized().unwrap());
1585
1586 git.cmd().args(["rm", "--cached", ".gitmodules"]).exec().unwrap();
1587 assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1588 assert!(git.has_missing_dependencies(["lib"]).is_err());
1589 }
1590
1591 #[test]
1592 fn keeps_submodule_status_in_worktree_without_gitmodules() {
1593 let tmp = tempdir().unwrap();
1594 let root = tmp.path().join("main");
1595 fs::create_dir(&root).unwrap();
1596 let git = Git::new(&root);
1597 git.init().unwrap();
1598 git.cmd()
1599 .args([
1600 "-c",
1601 "user.name=Foundry",
1602 "-c",
1603 "user.email=foundry@example.com",
1604 "commit",
1605 "--no-gpg-sign",
1606 "--allow-empty",
1607 "-m",
1608 "test: initialize worktree fixture",
1609 ])
1610 .exec()
1611 .unwrap();
1612 let worktree = tmp.path().join("worktree");
1613 git.cmd().args(["worktree", "add", "--detach"]).arg(&worktree).exec().unwrap();
1614
1615 assert!(worktree.join(".git").is_file());
1616 let git = git.root(&worktree);
1617 assert!(!git.submodules_initialized(&["lib".into()]).unwrap());
1618 assert!(git.has_missing_dependencies(["lib"]).is_err());
1619 }
1620}