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