Skip to main content

foundry_cli/utils/
mod.rs

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// reexport all `foundry_config::utils`
38#[doc(hidden)]
39pub use foundry_config::utils::*;
40
41/// Deterministic fuzzer seed used for gas snapshots and coverage reports.
42///
43/// The keccak256 hash of "foundry rulez"
44pub 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
49/// Applies an optional percentage multiplier to a gas estimate.
50pub 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
56/// Regex used to parse `.gitmodules` file and capture the submodule path and branch.
57pub static SUBMODULE_BRANCH_REGEX: LazyLock<Regex> =
58    LazyLock::new(|| Regex::new(r#"\[submodule "([^"]+)"\](?:[^\[]*?branch = ([^\s]+))"#).unwrap());
59/// Regex used to parse `git submodule status` output.
60pub static SUBMODULE_STATUS_REGEX: LazyLock<Regex> =
61    LazyLock::new(|| Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$").unwrap());
62
63/// Handle to reload the tracing `EnvFilter` at runtime.
64static FILTER_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
65    OnceLock::new();
66
67/// Useful extensions to [`std::path::Path`].
68pub trait FoundryPathExt {
69    /// Returns true if the [`Path`] ends with `.t.sol`
70    fn is_sol_test(&self) -> bool;
71
72    /// Returns true if the  [`Path`] has a `sol` extension
73    fn is_sol(&self) -> bool;
74
75    /// Returns true if the  [`Path`] has a `yul` extension
76    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
97/// Initializes a tracing Subscriber for logging.
98///
99/// The `EnvFilter` is wrapped in a [`reload::Layer`] so it can be reconfigured at runtime via
100/// [`update_tracing_filter`].
101pub 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
110/// Replaces the active tracing `EnvFilter` at runtime.
111///
112/// `directives` is parsed as an [`EnvFilter`] (e.g. `"info"`, `"debug,hyper=off"`).
113/// This is a no-op if [`subscriber`] has not been called yet.
114pub 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
133/// Returns a [`RootProvider`] instantiated using [Config]'s RPC settings.
134pub fn get_provider(config: &Config) -> Result<RootProvider<AnyNetwork>> {
135    get_provider_builder(config)?.build()
136}
137
138/// Returns a [ProviderBuilder] instantiated using [Config] values.
139///
140/// Defaults to `http://localhost:8545` and `Mainnet`.
141pub 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
156/// Parses an ether value from a string.
157///
158/// The amount can be tagged with a unit, e.g. "1ether".
159///
160/// If the string represents an untagged amount (e.g. "100") then
161/// it is interpreted as wei.
162pub 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
173/// Parses a `T` from a string using [`serde_json::from_str`].
174pub fn parse_json<T: DeserializeOwned>(value: &str) -> serde_json::Result<T> {
175    serde_json::from_str(value)
176}
177
178/// Parses a `Duration` from a &str
179pub 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
195/// Returns the current time as a [`Duration`] since the Unix epoch.
196pub fn now() -> Duration {
197    SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
198}
199
200/// Common setup for all CLI tools. Does not include [tracing subscriber](subscriber).
201pub 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
209/// Loads dotenv files from the cwd and project root, deferring diagnostics until shell setup.
210pub fn load_dotenv() -> Vec<String> {
211    // we only want the .env file of the cwd and project root
212    // `find_project_root` calls `current_dir` internally so both paths are either both `Ok` or
213    // both `Err`
214    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            // prj root and cwd can be identical
222            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            // Parse errors contain the source line, which may include secrets.
233            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
245/// Sets the default [`yansi`] color output condition.
246pub 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
251/// This force installs the default crypto provider.
252///
253/// This is necessary in case there are more than one available backends enabled in rustls (ring,
254/// aws-lc-rs).
255///
256/// This should be called high in the main fn.
257///
258/// See also:
259///   <https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455100010>
260///   <https://github.com/awslabs/aws-sdk-rust/discussions/1257>
261pub fn install_crypto_provider() {
262    // https://github.com/snapview/tokio-tungstenite/issues/353
263    rustls::crypto::ring::default_provider()
264        .install_default()
265        .expect("Failed to install default rustls crypto provider");
266}
267
268/// Fetches the ABI of a contract from Etherscan.
269///
270/// If `follow_proxy` is set and Etherscan reports the contract as a proxy, the ABI of its
271/// implementation is appended after the proxy's own. Failing to fetch the implementation only
272/// produces a warning.
273pub 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
310/// Useful extensions to [`std::process::Command`].
311pub trait CommandUtils {
312    /// Returns the command's output if execution is successful, otherwise, throws an error.
313    fn exec(&mut self) -> Result<Output>;
314
315    /// Returns the command's stdout if execution is successful, otherwise, throws an error.
316    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    /// True to perform shallow clones
440    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    /// Returns the current HEAD commit hash of the current branch.
457    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)] // this is not std::ops::Add clippy
479    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            // ignore "nothing to commit" error
534            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    /// Returns the tag the commit first appeared in.
624    ///
625    /// E.g Take rev = `abc1234`. This commit can be found in multiple releases (tags).
626    /// Consider releases: `v0.1.0`, `v0.2.0`, `v0.3.0` in chronological order, `rev` first appeared
627    /// in `v0.2.0`.
628    ///
629    /// Hence, `tag_for_commit("abc1234")` will return `v0.2.0`.
630    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    /// Returns a list of tuples of submodule paths and their respective branches.
639    ///
640    /// This function reads the `.gitmodules` file and returns the paths of all submodules that have
641    /// a branch. The paths are relative to the Git::root_of(git.root) and not lib/ directory.
642    ///
643    /// `at` is the dir in which the `.gitmodules` file is located, this is the git root.
644    /// `lib` is name of the directory where the submodules are located.
645    pub fn read_submodules_with_branch(
646        self,
647        at: &Path,
648        lib: &OsStr,
649    ) -> Result<HashMap<PathBuf, String>> {
650        // Read the .gitmodules file
651        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                // Keep only the components that come after the lib directory.
661                // This needs to be done because the lockfile uses paths relative foundry project
662                // root whereas .gitmodules use paths relative to the git root which may not be the
663                // project root. e.g monorepo.
664                // Hence, if path is lib/solady, then `lib/solady` is kept. if path is
665                // packages/contract-bedrock/lib/solady, then `lib/solady` is kept.
666                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    /// Returns true if all submodules matching `paths` have initialized worktrees.
698    fn submodules_initialized(self, paths: &[OsString]) -> Result<bool> {
699        // Let Git resolve relative roots.
700        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            // Removing .gitmodules does not remove gitlinks from the index. Let Git handle
718            // any remaining submodules, including those without a .gitmodules mapping.
719            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    /// Returns true if the given path has submodules by checking `git submodule status`
750    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    /// If the status is prefix with `-`, the submodule is not initialized.
818    ///
819    /// Ref: <https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-status--cached--recursive--ltpathgt82308203>
820    pub fn submodules_uninitialized(self) -> Result<bool> {
821        // keep behavior consistent with `has_missing_dependencies`, but avoid duplicating the
822        // "submodule status has '-' prefix" logic.
823        self.has_missing_dependencies(std::iter::empty::<&OsStr>())
824    }
825
826    /// Initializes the git submodules.
827    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    /// Returns submodules at or below `path`, with paths relative to this Git root.
836    pub fn submodules_in(&self, path: &Path) -> Result<Vec<SubmoduleCheckout>> {
837        self.submodules_in_worktree(path, self.root, Path::new(""))
838    }
839
840    /// Returns submodules at or below `path`, with paths relative to this Git root, using mappings
841    /// from the enclosing worktree.
842    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    /// Get the URL of a submodule from git config
932    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    /// Returns whether `.gitmodules` contains the default section name or an exact path mapping.
940    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    /// Returns whether the index contains a submodule at the given path.
991    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    /// Returns whether the index contains any entry at or below the given path.
1006    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    /// Returns whether a regular stage-0 index entry has no flags and matches the worktree.
1015    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    /// Returns whether local config contains values for the given submodule.
1045    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    /// Removes all local config values for the given submodule.
1059    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", &section]).exec()?;
1063        }
1064        Ok(())
1065    }
1066
1067    /// Returns the absolute path to the repository's Git directory.
1068    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    /// Returns the fetch URL of the given remote, or `None` if it doesn't exist.
1073    pub fn remote_url(self, name: &str) -> Option<String> {
1074        self.cmd().args(["remote", "get-url", name]).get_stdout_lossy().ok()
1075    }
1076
1077    /// Sets the branch for a submodule.
1078    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    /// Returns remote branch names as a newline-separated string.
1083    pub fn remote_branches(self) -> Result<String> {
1084        self.cmd().args(["branch", "-r"]).get_stdout_lossy()
1085    }
1086
1087    /// Fetches a branch from origin and checks out a local tracking branch at the given path.
1088    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    // don't set this in cmd() because it's not wanted for all commands
1126    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/// Deserialized `git submodule status lib/dep` output.
1139#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1140pub struct Submodule {
1141    /// Current commit hash the submodule is checked out at.
1142    rev: String,
1143    /// Relative path to the submodule.
1144    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/// A submodule checkout inspected without changing its worktree or index.
1162#[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/// State of an inspected submodule checkout.
1184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1185pub enum SubmoduleCheckoutStatus {
1186    /// The checkout matches the commit recorded by the superproject.
1187    Current,
1188    /// The submodule has not been initialized.
1189    Uninitialized,
1190    /// The checkout differs from the commit recorded by the superproject.
1191    Modified,
1192    /// The submodule has merge conflicts.
1193    Conflicted,
1194    /// The index contains a gitlink without a corresponding `.gitmodules` mapping.
1195    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/// Deserialized `git submodule status` output.
1214#[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        // The fast path succeeds even though this is not a real Git repository.
1379        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    // loads .env from cwd and project dir, See [`find_project_root()`]
1405    #[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}