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 `git submodule status` output.
57pub static SUBMODULE_STATUS_REGEX: LazyLock<Regex> =
58    LazyLock::new(|| Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$").unwrap());
59
60/// Handle to reload the tracing `EnvFilter` at runtime.
61static FILTER_RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, tracing_subscriber::Registry>> =
62    OnceLock::new();
63
64/// Useful extensions to [`std::path::Path`].
65pub trait FoundryPathExt {
66    /// Returns true if the [`Path`] ends with `.t.sol`
67    fn is_sol_test(&self) -> bool;
68
69    /// Returns true if the  [`Path`] has a `sol` extension
70    fn is_sol(&self) -> bool;
71
72    /// Returns true if the  [`Path`] has a `yul` extension
73    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
94/// Initializes a tracing Subscriber for logging.
95///
96/// The `EnvFilter` is wrapped in a [`reload::Layer`] so it can be reconfigured at runtime via
97/// [`update_tracing_filter`].
98pub 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
107/// Replaces the active tracing `EnvFilter` at runtime.
108///
109/// `directives` is parsed as an [`EnvFilter`] (e.g. `"info"`, `"debug,hyper=off"`).
110/// This is a no-op if [`subscriber`] has not been called yet.
111pub 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
130/// Returns a [`RootProvider`] instantiated using [Config]'s RPC settings.
131pub fn get_provider(config: &Config) -> Result<RootProvider<AnyNetwork>> {
132    get_provider_builder(config)?.build()
133}
134
135/// Returns a [ProviderBuilder] instantiated using [Config] values.
136///
137/// Defaults to `http://localhost:8545` and `Mainnet`.
138pub 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
153/// Parses an ether value from a string.
154///
155/// The amount can be tagged with a unit, e.g. "1ether".
156///
157/// If the string represents an untagged amount (e.g. "100") then
158/// it is interpreted as wei.
159pub 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
170/// Parses a `T` from a string using [`serde_json::from_str`].
171pub fn parse_json<T: DeserializeOwned>(value: &str) -> serde_json::Result<T> {
172    serde_json::from_str(value)
173}
174
175/// Parses a `Duration` from a &str
176pub 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
192/// Returns the current time as a [`Duration`] since the Unix epoch.
193pub fn now() -> Duration {
194    SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
195}
196
197/// Common setup for all CLI tools. Does not include [tracing subscriber](subscriber).
198pub 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
206/// Loads dotenv files from the cwd and project root, deferring diagnostics until shell setup.
207pub fn load_dotenv() -> Vec<String> {
208    // we only want the .env file of the cwd and project root
209    // `find_project_root` calls `current_dir` internally so both paths are either both `Ok` or
210    // both `Err`
211    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            // prj root and cwd can be identical
219            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            // Parse errors contain the source line, which may include secrets.
230            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
242/// Sets the default [`yansi`] color output condition.
243pub 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
248/// This force installs the default crypto provider.
249///
250/// This is necessary in case there are more than one available backends enabled in rustls (ring,
251/// aws-lc-rs).
252///
253/// This should be called high in the main fn.
254///
255/// See also:
256///   <https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455100010>
257///   <https://github.com/awslabs/aws-sdk-rust/discussions/1257>
258pub fn install_crypto_provider() {
259    // https://github.com/snapview/tokio-tungstenite/issues/353
260    rustls::crypto::ring::default_provider()
261        .install_default()
262        .expect("Failed to install default rustls crypto provider");
263}
264
265/// Fetches the ABI of a contract from Etherscan.
266///
267/// If `follow_proxy` is set and Etherscan reports the contract as a proxy, the ABI of its
268/// implementation is appended after the proxy's own. Failing to fetch the implementation only
269/// produces a warning.
270pub 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
307/// Useful extensions to [`std::process::Command`].
308pub trait CommandUtils {
309    /// Returns the command's output if execution is successful, otherwise, throws an error.
310    fn exec(&mut self) -> Result<Output>;
311
312    /// Returns the command's stdout if execution is successful, otherwise, throws an error.
313    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    /// True to perform shallow clones
437    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    /// Returns the current HEAD commit hash of the current branch.
454    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)] // this is not std::ops::Add clippy
476    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            // ignore "nothing to commit" error
531            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    /// Returns the tag the commit first appeared in.
621    ///
622    /// E.g Take rev = `abc1234`. This commit can be found in multiple releases (tags).
623    /// Consider releases: `v0.1.0`, `v0.2.0`, `v0.3.0` in chronological order, `rev` first appeared
624    /// in `v0.2.0`.
625    ///
626    /// Hence, `tag_for_commit("abc1234")` will return `v0.2.0`.
627    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    /// Returns a list of tuples of submodule paths and their respective branches.
636    ///
637    /// This function reads the `.gitmodules` file and returns the paths of all submodules that have
638    /// a branch. The paths are relative to the Git::root_of(git.root) and not lib/ directory.
639    ///
640    /// `at` is the dir in which the `.gitmodules` file is located, this is the git root.
641    /// `lib` is name of the directory where the submodules are located.
642    pub fn read_submodules_with_branch(
643        self,
644        at: &Path,
645        lib: &OsStr,
646    ) -> Result<HashMap<PathBuf, String>> {
647        // Let Git decode config quoting, escapes, whitespace, and comments.
648        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        // Resolve paths using Git's last-value semantics, but retain branch record order so
657        // the last branch wins when different submodule paths normalize to the same path.
658        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                // Keep only the components that come after the lib directory.
668                // This needs to be done because the lockfile uses paths relative foundry project
669                // root whereas .gitmodules use paths relative to the git root which may not be the
670                // project root. e.g monorepo.
671                // Hence, if path is lib/solady, then `lib/solady` is kept. if path is
672                // packages/contract-bedrock/lib/solady, then `lib/solady` is kept.
673                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    /// Returns true if all submodules matching `paths` have initialized worktrees.
704    fn submodules_initialized(self, paths: &[OsString]) -> Result<bool> {
705        // Let Git resolve relative roots.
706        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            // Removing .gitmodules does not remove gitlinks from the index. Let Git handle
724            // any remaining submodules, including those without a .gitmodules mapping.
725            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    /// Returns true if the given path has submodules by checking `git submodule status`
756    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    /// If the status is prefix with `-`, the submodule is not initialized.
824    ///
825    /// Ref: <https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-status--cached--recursive--ltpathgt82308203>
826    pub fn submodules_uninitialized(self) -> Result<bool> {
827        // keep behavior consistent with `has_missing_dependencies`, but avoid duplicating the
828        // "submodule status has '-' prefix" logic.
829        self.has_missing_dependencies(std::iter::empty::<&OsStr>())
830    }
831
832    /// Initializes the git submodules.
833    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    /// Returns submodules at or below `path`, with paths relative to this Git root.
842    pub fn submodules_in(&self, path: &Path) -> Result<Vec<SubmoduleCheckout>> {
843        self.submodules_in_worktree(path, self.root, Path::new(""))
844    }
845
846    /// Returns submodules at or below `path`, with paths relative to this Git root, using mappings
847    /// from the enclosing worktree.
848    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    /// Get the URL of a submodule from git config
938    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    /// Returns whether `.gitmodules` contains the default section name or an exact path mapping.
946    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    /// Returns whether the index contains a submodule at the given path.
997    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    /// Returns whether the index contains any entry at or below the given path.
1012    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    /// Returns whether a regular stage-0 index entry has no flags and matches the worktree.
1021    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    /// Returns whether local config contains values for the given submodule.
1051    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    /// Removes all local config values for the given submodule.
1065    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", &section]).exec()?;
1069        }
1070        Ok(())
1071    }
1072
1073    /// Returns the absolute path to the repository's Git directory.
1074    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    /// Returns the fetch URL of the given remote, or `None` if it doesn't exist.
1079    pub fn remote_url(self, name: &str) -> Option<String> {
1080        self.cmd().args(["remote", "get-url", name]).get_stdout_lossy().ok()
1081    }
1082
1083    /// Sets the branch for a submodule.
1084    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    /// Returns remote branch names as a newline-separated string.
1093    pub fn remote_branches(self) -> Result<String> {
1094        self.cmd().args(["branch", "-r", "--no-color", "--no-column"]).get_stdout_lossy()
1095    }
1096
1097    /// Fetches a branch from origin and checks out a local tracking branch at the given path.
1098    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    /// Checks out a branch without fetching or resetting an existing local branch.
1118    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    // don't set this in cmd() because it's not wanted for all commands
1150    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/// Deserialized `git submodule status lib/dep` output.
1163#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1164pub struct Submodule {
1165    /// Current commit hash the submodule is checked out at.
1166    rev: String,
1167    /// Relative path to the submodule.
1168    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/// A submodule checkout inspected without changing its worktree or index.
1186#[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/// State of an inspected submodule checkout.
1208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1209pub enum SubmoduleCheckoutStatus {
1210    /// The checkout matches the commit recorded by the superproject.
1211    Current,
1212    /// The submodule has not been initialized.
1213    Uninitialized,
1214    /// The checkout differs from the commit recorded by the superproject.
1215    Modified,
1216    /// The submodule has merge conflicts.
1217    Conflicted,
1218    /// The index contains a gitlink without a corresponding `.gitmodules` mapping.
1219    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/// Deserialized `git submodule status` output.
1238#[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        // The fast path succeeds even though this is not a real Git repository.
1404        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    // loads .env from cwd and project dir, See [`find_project_root()`]
1430    #[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}