diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 391d519d05621..5dadb443f81e2 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -1,5 +1,5 @@ use alloy_json_abi::JsonAbi; -use alloy_primitives::U256; +use alloy_primitives::{map::HashMap, U256}; use alloy_provider::{network::AnyNetwork, Provider}; use eyre::{ContextCompat, Result}; use foundry_common::{ @@ -7,12 +7,14 @@ use foundry_common::{ shell, }; use foundry_config::{Chain, Config}; +use itertools::Itertools; use serde::de::DeserializeOwned; use std::{ ffi::OsStr, future::Future, path::{Path, PathBuf}, process::{Command, Output, Stdio}, + str::FromStr, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tracing_subscriber::prelude::*; @@ -41,6 +43,13 @@ pub const STATIC_FUZZ_SEED: [u8; 32] = [ 0x5d, 0x64, 0x0b, 0x19, 0xad, 0xf0, 0xe3, 0x57, 0xb8, 0xd4, 0xbe, 0x7d, 0x49, 0xee, 0x70, 0xe6, ]; +/// Regex used to parse `.gitmodules` file and capture the submodule path and branch. +const SUBMODULE_BRANCH_REGEX: &str = r#"\[submodule "([^"]+)"\](?:[^\[]*?branch = ([^\s]+))"#; +/// Regex used to parse `git submodule status` output. +const SUBMODULE_STATUS_REGEX: &str = r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$"; +/// Capture the HEAD / default branch of the git repository from `git remote show origin`. +const DEFAULT_BRANCH_REGEX: &str = r"HEAD branch: (.*)"; + /// Useful extensions to [`std::path::Path`]. pub trait FoundryPathExt { /// Returns true if the [`Path`] ends with `.t.sol` @@ -392,10 +401,21 @@ impl<'a> Git<'a> { .map(drop) } + pub fn checkout_at(self, tag: impl AsRef, at: &Path) -> Result<()> { + self.cmd_at(at).arg("checkout").arg(tag).exec().map(drop) + } + pub fn init(self) -> Result<()> { self.cmd().arg("init").exec().map(drop) } + pub fn current_rev_branch(self, at: &Path) -> Result<(String, String)> { + let rev = self.cmd_at(at).args(["rev-parse", "HEAD"]).get_stdout_lossy()?; + let branch = + self.cmd_at(at).args(["rev-parse", "--abbrev-ref", "HEAD"]).get_stdout_lossy()?; + Ok((rev, branch)) + } + #[expect(clippy::should_implement_trait)] // this is not std::ops::Add clippy pub fn add(self, paths: I) -> Result<()> where @@ -469,6 +489,26 @@ impl<'a> Git<'a> { .map(|stdout| !stdout.is_empty()) } + pub fn has_tag(self, tag: impl AsRef, at: &Path) -> Result { + self.cmd_at(at) + .args(["tag", "--list"]) + .arg(tag) + .get_stdout_lossy() + .map(|stdout| !stdout.is_empty()) + } + + pub fn has_rev(self, rev: impl AsRef, at: &Path) -> Result { + self.cmd_at(at) + .args(["cat-file", "-t"]) + .arg(rev) + .get_stdout_lossy() + .map(|stdout| &stdout == "commit") + } + + pub fn get_rev(self, tag_or_branch: impl AsRef, at: &Path) -> Result { + self.cmd_at(at).args(["rev-list", "-n", "1"]).arg(tag_or_branch).get_stdout_lossy() + } + pub fn ensure_clean(self) -> Result<()> { if self.is_clean()? { Ok(()) @@ -497,6 +537,64 @@ ignore them in the `.gitignore` file." self.cmd().arg("tag").get_stdout_lossy() } + /// Returns the tag the commit first appeared in. + /// + /// E.g Take rev = `abc1234`. This commit can be found in multiple releases (tags). + /// Consider releases: `v0.1.0`, `v0.2.0`, `v0.3.0` in chronological order, `rev` first appeared + /// in `v0.2.0`. + /// + /// Hence, `tag_for_commit("abc1234")` will return `v0.2.0`. + pub fn tag_for_commit(self, rev: &str, at: &Path) -> Result> { + self.cmd_at(at) + .args(["tag", "--contains"]) + .arg(rev) + .get_stdout_lossy() + .map(|stdout| stdout.lines().next().map(str::to_string)) + } + + /// Returns a list of tuples of submodule paths and their respective branches. + /// + /// This function reads the `.gitmodules` file and returns the paths of all submodules that have + /// a branch. The paths are relative to the Git::root_of(git.root) and not lib/ directory. + /// + /// `at` is the dir in which the `.gitmodules` file is located, this is the git root. + /// `lib` is name of the directory where the submodules are located. + pub fn read_submodules_with_branch( + self, + at: &Path, + lib: &OsStr, + ) -> Result> { + // Read the .gitmodules file + let gitmodules = foundry_common::fs::read_to_string(at.join(".gitmodules"))?; + let re = regex::Regex::new(SUBMODULE_BRANCH_REGEX)?; + + let paths = re + .captures_iter(&gitmodules) + .map(|cap| { + let path_str = cap.get(1).unwrap().as_str(); + let path = PathBuf::from_str(path_str).unwrap(); + trace!(path = %path.display(), "unstripped path"); + + // Keep only the components that come after the lib directory. + // This needs to be done because the lockfile uses paths relative foundry project + // root whereas .gitmodules use paths relative to the git root which may not be the + // project root. e.g monorepo. + // Hence, if path is lib/solady, then `lib/solady` is kept. if path is + // packages/contract-bedrock/lib/solady, then `lib/solady` is kept. + let lib_pos = path.components().find_position(|c| c.as_os_str() == lib); + let path = path + .components() + .skip(lib_pos.map(|(i, _)| i).unwrap_or(0)) + .collect::(); + + let branch = cap.get(2).unwrap().as_str().to_string(); + (path, branch) + }) + .collect::>(); + + Ok(paths) + } + pub fn has_missing_dependencies(self, paths: I) -> Result where I: IntoIterator, @@ -574,10 +672,35 @@ ignore them in the `.gitignore` file." .map(drop) } + /// If the status is prefix with `-`, the submodule is not initialized. + /// + /// Ref: + pub fn submodules_unintialized(self) -> Result { + self.cmd() + .args(["submodule", "status"]) + .get_stdout_lossy() + .map(|stdout| stdout.lines().any(|line| line.starts_with('-'))) + } + + /// Initializes the git submodules. pub fn submodule_init(self) -> Result<()> { self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop) } + /// Gets the default branch of the git repository. + pub fn default_branch(&self, at: &Path) -> Result { + self.cmd_at(at).args(["remote", "show", "origin"]).get_stdout_lossy().map(|stdout| { + let re = regex::Regex::new(DEFAULT_BRANCH_REGEX)?; + let caps = + re.captures(&stdout).ok_or_else(|| eyre::eyre!("Could not find HEAD branch"))?; + Ok(caps.get(1).unwrap().as_str().to_string()) + })? + } + + pub fn submodules(&self) -> Result { + self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())? + } + pub fn submodule_sync(self) -> Result<()> { self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop) } @@ -610,6 +733,75 @@ ignore them in the `.gitignore` file." } } +/// Deserialized `git submodule status lib/dep` output. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct Submodule { + /// Current commit hash the submodule is checked out at. + rev: String, + /// Relative path to the submodule. + path: PathBuf, +} + +impl Submodule { + pub fn new(rev: String, path: PathBuf) -> Self { + Self { rev, path } + } + + pub fn rev(&self) -> &str { + &self.rev + } + + pub fn path(&self) -> &PathBuf { + &self.path + } +} + +impl FromStr for Submodule { + type Err = eyre::Report; + + fn from_str(s: &str) -> Result { + let re = regex::Regex::new(SUBMODULE_STATUS_REGEX)?; + + let caps = re.captures(s).ok_or_else(|| eyre::eyre!("Invalid submodule status format"))?; + + Ok(Self { + rev: caps.get(1).unwrap().as_str().to_string(), + path: PathBuf::from(caps.get(2).unwrap().as_str()), + }) + } +} + +/// Deserialized `git submodule status` output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Submodules(pub Vec); + +impl Submodules { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl FromStr for Submodules { + type Err = eyre::Report; + + fn from_str(s: &str) -> Result { + let subs = s.lines().map(str::parse).collect::>>()?; + Ok(Self(subs)) + } +} + +impl<'a> IntoIterator for &'a Submodules { + type Item = &'a Submodule; + type IntoIter = std::slice::Iter<'a, Submodule>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} #[cfg(test)] mod tests { use super::*; @@ -617,6 +809,37 @@ mod tests { use std::{env, fs::File, io::Write}; use tempfile::tempdir; + #[test] + fn parse_submodule_status() { + let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)"; + let sub = Submodule::from_str(s).unwrap(); + assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8"); + assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts")); + + let s = "-8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts"; + let sub = Submodule::from_str(s).unwrap(); + assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8"); + assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts")); + + let s = "8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts"; + let sub = Submodule::from_str(s).unwrap(); + assert_eq!(sub.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8"); + assert_eq!(sub.path(), Path::new("lib/openzeppelin-contracts")); + } + + #[test] + fn parse_multiline_submodule_status() { + let s = r#"+d3db4ef90a72b7d24aa5a2e5c649593eaef7801d lib/forge-std (v1.9.4-6-gd3db4ef) ++8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a) +"#; + let subs = Submodules::from_str(s).unwrap().0; + assert_eq!(subs.len(), 2); + assert_eq!(subs[0].rev(), "d3db4ef90a72b7d24aa5a2e5c649593eaef7801d"); + assert_eq!(subs[0].path(), Path::new("lib/forge-std")); + assert_eq!(subs[1].rev(), "8829465a08cac423dcf59852f21e448449c1a1a8"); + assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts")); + } + #[test] fn foundry_path_ext_works() { let p = Path::new("contracts/MyTest.t.sol"); @@ -653,4 +876,92 @@ mod tests { assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val"); assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val"); } + + #[test] + fn test_read_gitmodules_regex() { + // Regex to read and return the paths of all submodules that have a branch + let re = regex::Regex::new(SUBMODULE_BRANCH_REGEX).unwrap(); + + let gitmodules = r#" + [submodule "lib/solady"] + path = lib/solady + url = "" + branch = v0.1.0 + [submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = "" + branch = v4.8.0-791-g8829465a + [submodule "lib/forge-std"] + path = lib/forge-std + url = "" +"#; + + let paths = re + .captures_iter(gitmodules) + .map(|cap| { + ( + PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(), + String::from(cap.get(2).unwrap().as_str()), + ) + }) + .collect::>(); + + assert_eq!(paths.get(Path::new("lib/solady")).unwrap(), "v0.1.0"); + assert_eq!( + paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(), + "v4.8.0-791-g8829465a" + ); + + let no_branch_gitmodules = r#" + [submodule "lib/solady"] + path = lib/solady + url = "" + [submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = "" + [submodule "lib/forge-std"] + path = lib/forge-std + url = "" +"#; + let paths = re + .captures_iter(no_branch_gitmodules) + .map(|cap| { + ( + PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(), + String::from(cap.get(2).unwrap().as_str()), + ) + }) + .collect::>(); + + assert!(paths.is_empty()); + + let branch_in_between = r#" + [submodule "lib/solady"] + path = lib/solady + url = "" + [submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = "" + branch = v4.8.0-791-g8829465a + [submodule "lib/forge-std"] + path = lib/forge-std + url = "" + "#; + + let paths = re + .captures_iter(branch_in_between) + .map(|cap| { + ( + PathBuf::from_str(cap.get(1).unwrap().as_str()).unwrap(), + String::from(cap.get(2).unwrap().as_str()), + ) + }) + .collect::>(); + + assert_eq!(paths.len(), 1); + assert_eq!( + paths.get(Path::new("lib/openzeppelin-contracts")).unwrap(), + "v4.8.0-791-g8829465a" + ); + } } diff --git a/crates/forge/src/cmd/install.rs b/crates/forge/src/cmd/install.rs index 0a88c6eca7ab1..9586d7fb2f7fc 100644 --- a/crates/forge/src/cmd/install.rs +++ b/crates/forge/src/cmd/install.rs @@ -1,3 +1,4 @@ +use crate::{DepIdentifier, Lockfile, FOUNDRY_LOCK}; use clap::{Parser, ValueHint}; use eyre::{Context, Result}; use foundry_cli::{ @@ -115,7 +116,22 @@ impl DependencyInstallOpts { let install_lib_dir = config.install_lib_dir(); let libs = git.root.join(install_lib_dir); - if dependencies.is_empty() && !self.no_git { + let mut lockfile = Lockfile::new(&config.root); + if !no_git { + lockfile = lockfile.with_git(&git); + + // Check if submodules are uninitialized, if so, we need to fetch all submodules + // This is to ensure that foundry.lock syncs successfully and doesn't error out, when + // looking for commits/tags in submodules + if git.submodules_unintialized()? { + trace!(lib = %libs.display(), "submodules uninitialized"); + git.submodule_update(false, false, false, true, Some(&libs))?; + } + } + + let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?; + + if dependencies.is_empty() && !no_git { // Use the root of the git repository to look for submodules. let root = Git::root_of(git.root)?; match git.has_submodules(Some(&root)) { @@ -124,6 +140,7 @@ impl DependencyInstallOpts { // recursively fetch all submodules (without fetching latest) git.submodule_update(false, false, false, true, Some(&libs))?; + lockfile.write()?; } Err(err) => { @@ -153,6 +170,7 @@ impl DependencyInstallOpts { // this tracks the actual installed tag let installed_tag; + let mut dep_id = None; if no_git { installed_tag = installer.install_as_folder(&dep, &path)?; } else { @@ -161,15 +179,33 @@ impl DependencyInstallOpts { } installed_tag = installer.install_as_submodule(&dep, &path)?; + let mut new_insertion = false; // Pin branch to submodule if branch is used - if let Some(branch) = &installed_tag { + if let Some(tag_or_branch) = &installed_tag { // First, check if this tag has a branch - if git.has_branch(branch, &path)? { + dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?); + if git.has_branch(tag_or_branch, &path)? && + dep_id.as_ref().is_some_and(|id| id.is_branch()) + { // always work with relative paths when directly modifying submodules git.cmd() - .args(["submodule", "set-branch", "-b", branch]) + .args(["submodule", "set-branch", "-b", tag_or_branch]) .arg(rel_path) .exec()?; + + let rev = git.get_rev(tag_or_branch, &path)?; + + dep_id = Some(DepIdentifier::Branch { + name: tag_or_branch.to_string(), + rev, + r#override: false, + }); + } + + trace!(?dep_id, ?tag_or_branch, "resolved dep id"); + if let Some(dep_id) = &dep_id { + new_insertion = true; + lockfile.insert(rel_path.to_path_buf(), dep_id.clone()); } if commit { @@ -180,14 +216,31 @@ impl DependencyInstallOpts { } } + if new_insertion || + out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty()) || + !lockfile.exists() + { + lockfile.write()?; + } + // commit the installation if commit { let mut msg = String::with_capacity(128); msg.push_str("forge install: "); msg.push_str(dep.name()); + if let Some(tag) = &installed_tag { msg.push_str("\n\n"); - msg.push_str(tag); + + if let Some(dep_id) = &dep_id { + msg.push_str(dep_id.to_string().as_str()); + } else { + msg.push_str(tag); + } + } + + if !lockfile.is_empty() { + git.root(&config.root).add(Some(FOUNDRY_LOCK))?; } git.commit(&msg)?; } @@ -196,7 +249,12 @@ impl DependencyInstallOpts { let mut msg = format!(" {} {}", "Installed".green(), dep.name); if let Some(tag) = dep.tag.or(installed_tag) { msg.push(' '); - msg.push_str(tag.as_str()); + + if let Some(dep_id) = dep_id { + msg.push_str(dep_id.to_string().as_str()); + } else { + msg.push_str(tag.as_str()); + } } sh_println!("{msg}")?; } @@ -206,6 +264,7 @@ impl DependencyInstallOpts { config.libs.push(install_lib_dir.to_path_buf()); config.update_libs()?; } + Ok(()) } } diff --git a/crates/forge/src/cmd/remove.rs b/crates/forge/src/cmd/remove.rs index 2033ad3a7c4ad..9e4629a5b726b 100644 --- a/crates/forge/src/cmd/remove.rs +++ b/crates/forge/src/cmd/remove.rs @@ -1,3 +1,4 @@ +use crate::Lockfile; use clap::{Parser, ValueHint}; use eyre::Result; use foundry_cli::{ @@ -30,18 +31,25 @@ impl_figment_convert_basic!(RemoveArgs); impl RemoveArgs { pub fn run(self) -> Result<()> { let config = self.load_config()?; - let (root, paths) = super::update::dependencies_paths(&self.dependencies, &config)?; + let (root, paths, _) = super::update::dependencies_paths(&self.dependencies, &config)?; let git_modules = root.join(".git/modules"); + let git = Git::new(&root); + let mut lockfile = Lockfile::new(&config.root).with_git(&git); + let _synced = lockfile.sync(config.install_lib_dir())?; + // remove all the dependencies by invoking `git rm` only once with all the paths - Git::new(&root).rm(self.force, &paths)?; + git.rm(self.force, &paths)?; // remove all the dependencies from .git/modules for (Dependency { name, url, tag, .. }, path) in self.dependencies.iter().zip(&paths) { sh_println!("Removing '{name}' in {}, (url: {url:?}, tag: {tag:?})", path.display())?; + let _ = lockfile.remove(path); std::fs::remove_dir_all(git_modules.join(path))?; } + lockfile.write()?; + Ok(()) } } diff --git a/crates/forge/src/cmd/update.rs b/crates/forge/src/cmd/update.rs index 5e965c34a9999..02a555a629bc3 100644 --- a/crates/forge/src/cmd/update.rs +++ b/crates/forge/src/cmd/update.rs @@ -1,3 +1,5 @@ +use crate::{DepIdentifier, DepMap, Lockfile}; +use alloy_primitives::map::HashMap; use clap::{Parser, ValueHint}; use eyre::{Context, Result}; use foundry_cli::{ @@ -6,6 +8,7 @@ use foundry_cli::{ }; use foundry_config::{impl_figment_convert_basic, Config}; use std::path::PathBuf; +use yansi::Paint; /// CLI arguments for `forge update`. #[derive(Clone, Debug, Parser)] @@ -33,39 +36,179 @@ impl_figment_convert_basic!(UpdateArgs); impl UpdateArgs { pub fn run(self) -> Result<()> { let config = self.load_config()?; - let (root, paths) = dependencies_paths(&self.dependencies, &config)?; + // dep_overrides consists of absolute paths of dependencies and their tags + let (root, _paths, dep_overrides) = dependencies_paths(&self.dependencies, &config)?; + // Mapping of relative path of lib to its tag type + // e.g "lib/forge-std" -> DepIdentifier::Tag { name: "v0.1.0", rev: "1234567" } + let git = Git::new(&root); + + let mut foundry_lock = Lockfile::new(&config.root).with_git(&git); + let out_of_sync_deps = foundry_lock.sync(config.install_lib_dir())?; + + // update the submodules' tags if any overrides are present + let mut prev_dep_ids: DepMap = HashMap::default(); + if dep_overrides.is_empty() { + // running `forge update`, update all deps + foundry_lock.iter_mut().for_each(|(_path, dep_id)| { + // Set r#override flag to true if the dep is a branch + if let DepIdentifier::Branch { .. } = dep_id { + dep_id.mark_overide(); + } + }); + } else { + for (dep_path, override_tag) in &dep_overrides { + let rel_path = dep_path + .strip_prefix(&root) + .wrap_err("Dependency path is not relative to the repository root")?; + if let Ok(dep_id) = DepIdentifier::resolve_type(&git, dep_path, override_tag) { + let prev = foundry_lock.override_dep(rel_path, dep_id)?; + prev_dep_ids.insert(rel_path.to_owned(), prev); + } else { + sh_warn!( + "Could not r#override submodule at {} with tag {}, try using forge install", + rel_path.display(), + override_tag + )?; + } + } + } + // fetch the latest changes for each submodule (recursively if flag is set) let git = Git::new(&root); + let update_paths = self.update_dep_paths(&foundry_lock); + trace!(?update_paths, "updating deps at"); + if self.recursive { // update submodules recursively - git.submodule_update(self.force, true, false, true, paths) + git.submodule_update(self.force, true, false, true, update_paths)?; } else { - // update root submodules - git.submodule_update(self.force, true, false, false, paths)?; - // initialize submodules of each submodule recursively (otherwise direct submodule - // dependencies will revert to last commit) - git.submodule_foreach(false, "git submodule update --init --progress --recursive") + let is_empty = update_paths.is_empty(); + + // update submodules + git.submodule_update(self.force, true, false, false, update_paths)?; + + if !is_empty { + // initialize submodules of each submodule recursively (otherwise direct submodule + // dependencies will revert to last commit) + git.submodule_foreach(false, "git submodule update --init --progress --recursive")?; + } + } + + // Branches should get updated to their latest commit on `forge update`. + // i.e if previously submodule was tracking branch `main` at rev `1234567` and now the + // remote `main` branch is at `7654321`, then submodule should also be updated to `7654321`. + // This tracking is automatically handled by git, but we need to update the lockfile entry + // to reflect the latest commit. + if dep_overrides.is_empty() { + let branch_overrides = foundry_lock + .iter_mut() + .filter_map(|(path, dep_id)| { + if dep_id.is_branch() && dep_id.overridden() { + return Some((path, dep_id)) + } + None + }) + .collect::>(); + + for (path, dep_id) in branch_overrides { + let (curr_rev, curr_branch) = git.current_rev_branch(&root.join(path))?; + let name = dep_id.name(); + // This can occur when the submodule is manually checked out to a different branch. + if curr_branch != name { + let warn_msg = format!( + r#"Lockfile sync warning + Lockfile is tracking branch {name} for submodule at {path:?}, but the submodule is currently on {curr_branch}. + Checking out branch {name} for submodule at {path:?}."#, + ); + let _ = sh_warn!("{}", warn_msg); + git.checkout_at(name, &root.join(path)).wrap_err(format!( + "Could not checkout branch {name} for submodule at {}", + path.display() + ))?; + } + + // Update the lockfile entry to reflect the latest commit + let prev = std::mem::replace( + dep_id, + DepIdentifier::Branch { + name: name.to_string(), + rev: curr_rev, + r#override: true, + }, + ); + prev_dep_ids.insert(path.to_owned(), prev); + } + } + + // checkout the submodules at the correct tags + for (path, dep_id) in foundry_lock.iter() { + git.checkout_at(dep_id.checkout_id(), &root.join(path))?; } + + if out_of_sync_deps.is_some_and(|o| !o.is_empty()) || + foundry_lock.iter().any(|(_, dep_id)| dep_id.overridden()) + { + foundry_lock.write()?; + } + + // Print updates from => to + for (path, prev) in prev_dep_ids { + let curr = foundry_lock.get(&path).unwrap(); + sh_println!( + "Updated dep at '{}', (from: {prev}, to: {curr})", + path.display().green(), + prev = prev, + curr = curr.yellow() + )?; + } + + Ok(()) + } + + /// Returns the `lib/paths` of the dependencies that have been updated/overridden. + fn update_dep_paths(&self, foundry_lock: &Lockfile<'_>) -> Vec { + foundry_lock + .iter() + .filter_map(|(path, dep_id)| { + if dep_id.overridden() { + return Some(path.to_path_buf()); + } + None + }) + .collect() } } -/// Returns `(root, paths)` where `root` is the root of the Git repository and `paths` are the -/// relative paths of the dependencies. -pub fn dependencies_paths(deps: &[Dependency], config: &Config) -> Result<(PathBuf, Vec)> { +/// Returns `(root, paths, overridden_deps_with_abosolute_paths)` where `root` is the root of the +/// Git repository and `paths` are the relative paths of the dependencies. +#[allow(clippy::type_complexity)] +pub fn dependencies_paths( + deps: &[Dependency], + config: &Config, +) -> Result<(PathBuf, Vec, HashMap)> { let git_root = Git::root_of(&config.root)?; let libs = config.install_lib_dir(); + if deps.is_empty() { + return Ok((git_root, Vec::new(), HashMap::default())); + } + let mut paths = Vec::with_capacity(deps.len()); + let mut overrides = HashMap::with_capacity_and_hasher(deps.len(), Default::default()); for dep in deps { let name = dep.name(); let dep_path = libs.join(name); + if !dep_path.exists() { + eyre::bail!("Could not find dependency {name:?} in {}", dep_path.display()); + } let rel_path = dep_path .strip_prefix(&git_root) .wrap_err("Library directory is not relative to the repository root")?; - if !dep_path.exists() { - eyre::bail!("Could not find dependency {name:?} in {}", dep_path.display()); + + if let Some(tag) = &dep.tag { + overrides.insert(dep_path.to_owned(), tag.to_owned()); } paths.push(rel_path.to_owned()); } - Ok((git_root, paths)) + Ok((git_root, paths, overrides)) } diff --git a/crates/forge/src/lib.rs b/crates/forge/src/lib.rs index fc2f71466bda4..b69198d8c56ea 100644 --- a/crates/forge/src/lib.rs +++ b/crates/forge/src/lib.rs @@ -29,3 +29,6 @@ pub mod result; // TODO: remove pub use foundry_common::traits::TestFilter; pub use foundry_evm::*; + +mod lockfile; +pub use lockfile::{DepIdentifier, DepMap, Lockfile, FOUNDRY_LOCK}; diff --git a/crates/forge/src/lockfile.rs b/crates/forge/src/lockfile.rs new file mode 100644 index 0000000000000..716f9ae3df2e1 --- /dev/null +++ b/crates/forge/src/lockfile.rs @@ -0,0 +1,385 @@ +//! foundry.lock handler type. + +use std::{ + collections::hash_map::Entry, + path::{Path, PathBuf}, +}; + +use alloy_primitives::map::HashMap; +use eyre::{OptionExt, Result}; +use foundry_cli::utils::Git; +use serde::{Deserialize, Serialize}; + +pub const FOUNDRY_LOCK: &str = "foundry.lock"; + +/// A type alias for a HashMap of dependencies keyed by relative path to the submodule dir. +pub type DepMap = HashMap; + +/// A lockfile handler that keeps track of the dependencies and their current state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Lockfile<'a> { + /// A map of the dependencies keyed by relative path to the submodule dir. + #[serde(flatten)] + deps: DepMap, + /// This is optional to handle no-git scencarios. + #[serde(skip)] + git: Option<&'a Git<'a>>, + /// Absolute path to the lockfile. + #[serde(skip)] + lockfile_path: PathBuf, +} + +impl<'a> Lockfile<'a> { + /// Create a new [`Lockfile`] instance. + /// + /// `project_root` is the absolute path to the project root. + /// + /// You will need to call [`Lockfile::read`] or [`Lockfile::sync`] to load the lockfile. + pub fn new(project_root: &Path) -> Self { + Self { deps: HashMap::default(), git: None, lockfile_path: project_root.join(FOUNDRY_LOCK) } + } + + /// Set the git instance to be used for submodule operations. + pub fn with_git(mut self, git: &'a Git<'_>) -> Self { + self.git = Some(git); + self + } + + /// Sync the foundry.lock file with the current state of `git submodules`. + /// + /// If the lockfile and git submodules are out of sync, it returns a [`DepMap`] consisting of + /// _only_ the out-of-sync dependencies. + /// + /// This method writes the lockfile to project root if: + /// - The lockfile does not exist. + /// - The lockfile is out of sync with the git submodules. + pub fn sync(&mut self, lib: &Path) -> Result> { + match self.read() { + Ok(_) => {} + Err(e) => { + if !e.to_string().contains("Lockfile not found") { + return Err(e); + } + } + } + + if let Some(git) = &self.git { + let submodules = git.submodules()?; + + if submodules.is_empty() { + trace!("No submodules found. Skipping sync."); + return Ok(None); + } + + let modules_with_branch = git + .read_submodules_with_branch(&Git::root_of(git.root)?, lib.file_name().unwrap())?; + + let mut out_of_sync: DepMap = HashMap::default(); + for sub in &submodules { + let rel_path = sub.path(); + let rev = sub.rev(); + + let entry = self.deps.entry(rel_path.to_path_buf()); + + match entry { + Entry::Occupied(e) => { + if e.get().rev() != rev { + out_of_sync.insert(rel_path.to_path_buf(), e.get().clone()); + } + } + Entry::Vacant(e) => { + // Check if there is branch specified for the submodule at rel_path in + // .gitmodules + let maybe_branch = modules_with_branch.get(rel_path).map(|b| b.to_string()); + + trace!(?maybe_branch, submodule = ?rel_path, "submodule branch"); + if let Some(branch) = maybe_branch { + let dep_id = DepIdentifier::Branch { + name: branch, + rev: rev.to_string(), + r#override: false, + }; + e.insert(dep_id.clone()); + out_of_sync.insert(rel_path.to_path_buf(), dep_id); + continue; + } + + let dep_id = DepIdentifier::Rev { rev: rev.to_string(), r#override: false }; + trace!(submodule=?rel_path, ?dep_id, "submodule dep_id"); + e.insert(dep_id.clone()); + out_of_sync.insert(rel_path.to_path_buf(), dep_id); + } + } + } + + return Ok(if out_of_sync.is_empty() { None } else { Some(out_of_sync) }); + } + + Ok(None) + } + + /// Loads the lockfile from the project root. + /// + /// Throws an error if the lockfile does not exist. + pub fn read(&mut self) -> Result<()> { + if !self.lockfile_path.exists() { + return Err(eyre::eyre!("Lockfile not found at {}", self.lockfile_path.display())); + } + + let lockfile_str = foundry_common::fs::read_to_string(&self.lockfile_path)?; + + self.deps = serde_json::from_str(&lockfile_str)?; + + trace!(lockfile = ?self.deps, "loaded lockfile"); + + Ok(()) + } + + /// Writes the lockfile to the project root. + pub fn write(&self) -> Result<()> { + foundry_common::fs::write_pretty_json_file(&self.lockfile_path, &self.deps)?; + trace!(at= ?self.lockfile_path, "wrote lockfile"); + + Ok(()) + } + + /// Insert a dependency into the lockfile. + /// If the dependency already exists, it will be updated. + /// + /// Note: This does not write the updated lockfile to disk, only inserts the dep in-memory. + pub fn insert(&mut self, path: PathBuf, dep_id: DepIdentifier) { + self.deps.insert(path, dep_id); + } + + /// Get the [`DepIdentifier`] for a submodule at a given path. + pub fn get(&self, path: &Path) -> Option<&DepIdentifier> { + self.deps.get(path) + } + + /// Removes a dependency from the lockfile. + /// + /// Note: This does not write the updated lockfile to disk, only removes the dep in-memory. + pub fn remove(&mut self, path: &Path) -> Option { + self.deps.remove(path) + } + + /// Override a dependency in the lockfile. + /// + /// Returns the overridden/previous [`DepIdentifier`]. + /// This is used in `forge update` to decide whether a dep's tag/branch/rev should be updated. + /// + /// Throws an error if the dependency is not found in the lockfile. + pub fn override_dep( + &mut self, + dep: &Path, + mut new_dep_id: DepIdentifier, + ) -> Result { + let prev = self + .deps + .get_mut(dep) + .map(|d| { + new_dep_id.mark_overide(); + std::mem::replace(d, new_dep_id) + }) + .ok_or_eyre(format!("Dependency not found in lockfile: {}", dep.display()))?; + + Ok(prev) + } + + /// Returns the num of dependencies in the lockfile. + pub fn len(&self) -> usize { + self.deps.len() + } + + /// Returns whether the lockfile is empty. + pub fn is_empty(&self) -> bool { + self.deps.is_empty() + } + + /// Returns an iterator over the lockfile. + pub fn iter(&self) -> impl Iterator { + self.deps.iter() + } + + /// Returns an mutable iterator over the lockfile. + pub fn iter_mut(&mut self) -> impl Iterator { + self.deps.iter_mut() + } + + pub fn exists(&self) -> bool { + self.lockfile_path.exists() + } +} + +// Implement .iter() for &LockFile + +/// Identifies whether a dependency (submodule) is referenced by a branch, +/// tag or rev (commit hash). +/// +/// Each enum variant consists of an `r#override` flag which is used in `forge update` to decide +/// whether to update a dep or not. This flag is skipped during serialization. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DepIdentifier { + /// `name` of the branch and the `rev` it is currently pointing to. + /// Running `forge update`, will update the `name` branch to the latest `rev`. + #[serde(rename = "branch")] + Branch { + name: String, + rev: String, + #[serde(skip)] + r#override: bool, + }, + /// Release tag `name` and the `rev` it is currently pointing to. + /// Running `forge update` does not update the tag/rev. + /// Dependency will remain pinned to the existing tag/rev unless r#override like so `forge + /// update owner/dep@tag=diffent_tag`. + #[serde(rename = "tag")] + Tag { + name: String, + rev: String, + #[serde(skip)] + r#override: bool, + }, + /// Commit hash `rev` the submodule is currently pointing to. + /// Running `forge update` does not update the rev. + /// Dependency will remain pinned to the existing rev unless r#override. + #[serde(rename = "rev", untagged)] + Rev { + rev: String, + #[serde(skip)] + r#override: bool, + }, +} + +impl DepIdentifier { + /// Resolves the [`DepIdentifier`] for a submodule at a given path. + /// `lib_path` is the absolute path to the submodule. + pub fn resolve_type(git: &Git<'_>, lib_path: &Path, s: &str) -> Result { + trace!(lib_path = ?lib_path, resolving_type = ?s, "resolving submodule identifier"); + // Get the tags for the submodule + if git.has_tag(s, lib_path)? { + let rev = git.get_rev(s, lib_path)?; + return Ok(Self::Tag { name: String::from(s), rev, r#override: false }); + } + + if git.has_branch(s, lib_path)? { + let rev = git.get_rev(s, lib_path)?; + return Ok(Self::Branch { name: String::from(s), rev, r#override: false }); + } + + if git.has_rev(s, lib_path)? { + return Ok(Self::Rev { rev: String::from(s), r#override: false }); + } + + Err(eyre::eyre!("Could not resolve tag type for submodule at path {}", lib_path.display())) + } + + /// Get the commit hash of the dependency. + pub fn rev(&self) -> &str { + match self { + Self::Branch { rev, .. } => rev, + Self::Tag { rev, .. } => rev, + Self::Rev { rev, .. } => rev, + } + } + + /// Get the name of the dependency. + /// + /// In case of a Rev, this will return the commit hash. + pub fn name(&self) -> &str { + match self { + Self::Branch { name, .. } => name, + Self::Tag { name, .. } => name, + Self::Rev { rev, .. } => rev, + } + } + + /// Get the name/rev to checkout at. + pub fn checkout_id(&self) -> &str { + match self { + Self::Branch { name, .. } => name, + Self::Tag { name, .. } => name, + Self::Rev { rev, .. } => rev, + } + } + + /// Marks as dependency as overridden. + pub fn mark_overide(&mut self) { + match self { + Self::Branch { r#override, .. } => *r#override = true, + Self::Tag { r#override, .. } => *r#override = true, + Self::Rev { r#override, .. } => *r#override = true, + } + } + + /// Returns whether the dependency has been overridden. + pub fn overridden(&self) -> bool { + match self { + Self::Branch { r#override, .. } => *r#override, + Self::Tag { r#override, .. } => *r#override, + Self::Rev { r#override, .. } => *r#override, + } + } + + /// Returns whether the dependency is a branch. + pub fn is_branch(&self) -> bool { + matches!(self, Self::Branch { .. }) + } +} + +impl std::fmt::Display for DepIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Branch { name, rev, .. } => write!(f, "branch={name}@{rev}"), + Self::Tag { name, rev, .. } => write!(f, "tag={name}@{rev}"), + Self::Rev { rev, .. } => write!(f, "rev={rev}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_dep_identifier() { + let branch = DepIdentifier::Branch { + name: "main".to_string(), + rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), + r#override: false, + }; + + let tag = DepIdentifier::Tag { + name: "v0.1.0".to_string(), + rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), + r#override: false, + }; + + let rev = DepIdentifier::Rev { + rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), + r#override: false, + }; + + let branch_str = serde_json::to_string(&branch).unwrap(); + let tag_str = serde_json::to_string(&tag).unwrap(); + let rev_str = serde_json::to_string(&rev).unwrap(); + + assert_eq!( + branch_str, + r#"{"branch":{"name":"main","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"# + ); + assert_eq!( + tag_str, + r#"{"tag":{"name":"v0.1.0","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"# + ); + assert_eq!(rev_str, r#"{"rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}"#); + + let branch_de: DepIdentifier = serde_json::from_str(&branch_str).unwrap(); + let tag_de: DepIdentifier = serde_json::from_str(&tag_str).unwrap(); + let rev_de: DepIdentifier = serde_json::from_str(&rev_str).unwrap(); + + assert_eq!(branch, branch_de); + assert_eq!(tag, tag_de); + assert_eq!(rev, rev_de); + } +} diff --git a/crates/forge/tests/cli/cmd.rs b/crates/forge/tests/cli/cmd.rs index ae9984f27f32e..6f93c390bda0a 100644 --- a/crates/forge/tests/cli/cmd.rs +++ b/crates/forge/tests/cli/cmd.rs @@ -9,7 +9,8 @@ use foundry_test_utils::{ foundry_compilers::PathStyle, rpc::next_etherscan_api_key, snapbox::IntoData, - util::{pretty_err, read_string, OutputExt, TestCommand}, + util::{read_string, OutputExt}, + TestCommand, }; use semver::Version; use std::{ @@ -3153,58 +3154,6 @@ Bindings have been generated to [..] "#]]); }); -// checks missing dependencies are auto installed -forgetest_init!(can_install_missing_deps_test, |prj, cmd| { - prj.clear(); - - // wipe forge-std - let forge_std_dir = prj.root().join("lib/forge-std"); - pretty_err(&forge_std_dir, fs::remove_dir_all(&forge_std_dir)); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -Missing dependencies found. Installing now... - -[UPDATING_DEPENDENCIES] -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 2 tests for test/Counter.t.sol:CounterTest -[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) -[PASS] test_Increment() ([GAS]) -Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) - -"#]]); -}); - -// checks missing dependencies are auto installed -forgetest_init!(can_install_missing_deps_build, |prj, cmd| { - prj.clear(); - - // wipe forge-std - let forge_std_dir = prj.root().join("lib/forge-std"); - pretty_err(&forge_std_dir, fs::remove_dir_all(&forge_std_dir)); - - // Build the project - cmd.arg("build").assert_success().stdout_eq(str![[r#" -Missing dependencies found. Installing now... - -[UPDATING_DEPENDENCIES] -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -"#]]); - - // Expect compilation to be skipped as no files have changed - cmd.forge_fuse().arg("build").assert_success().stdout_eq(str![[r#" -No files changed, compilation skipped - -"#]]); -}); - // checks that extra output works forgetest_init!(can_build_skip_contracts, |prj, cmd| { prj.clear(); diff --git a/crates/forge/tests/cli/install.rs b/crates/forge/tests/cli/install.rs new file mode 100644 index 0000000000000..7192d8233cdc9 --- /dev/null +++ b/crates/forge/tests/cli/install.rs @@ -0,0 +1,592 @@ +//! forge install and update tests + +use forge::{DepIdentifier, Lockfile, FOUNDRY_LOCK}; +use foundry_cli::utils::{Git, Submodules}; +use foundry_compilers::artifacts::Remapping; +use foundry_config::Config; +use foundry_test_utils::util::{ + pretty_err, read_string, ExtTester, TestCommand, FORGE_STD_REVISION, +}; +use semver::Version; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + str::FromStr, +}; + +fn lockfile_get(root: &Path, dep_path: &Path) -> Option { + let mut l = Lockfile::new(root); + l.read().unwrap(); + l.get(dep_path).cloned() +} +// checks missing dependencies are auto installed +forgetest_init!(can_install_missing_deps_build, |prj, cmd| { + prj.clear(); + + // wipe forge-std + let forge_std_dir = prj.root().join("lib/forge-std"); + pretty_err(&forge_std_dir, fs::remove_dir_all(&forge_std_dir)); + + // Build the project + cmd.arg("build").assert_success().stdout_eq(str![[r#" +Missing dependencies found. Installing now... + +[UPDATING_DEPENDENCIES] +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]); + + // assert lockfile + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert_eq!(forge_std.rev(), FORGE_STD_REVISION); + + // Expect compilation to be skipped as no files have changed + cmd.forge_fuse().arg("build").assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped + +"#]]); +}); + +// checks missing dependencies are auto installed +forgetest_init!(can_install_missing_deps_test, |prj, cmd| { + prj.clear(); + + // wipe forge-std + let forge_std_dir = prj.root().join("lib/forge-std"); + pretty_err(&forge_std_dir, fs::remove_dir_all(&forge_std_dir)); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +Missing dependencies found. Installing now... + +[UPDATING_DEPENDENCIES] +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/Counter.t.sol:CounterTest +[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) +[PASS] test_Increment() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); + + // assert lockfile + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert_eq!(forge_std.rev(), FORGE_STD_REVISION); +}); + +// test to check that install/remove works properly +forgetest!(can_install_and_remove, |prj, cmd| { + cmd.git_init(); + + let libs = prj.root().join("lib"); + let git_mod = prj.root().join(".git/modules/lib"); + let git_mod_file = prj.root().join(".gitmodules"); + + let forge_std = libs.join("forge-std"); + let forge_std_mod = git_mod.join("forge-std"); + + let install = |cmd: &mut TestCommand| { + cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq( + str![[r#" +Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) + Installed forge-std[..] + +"#]], + ); + + assert!(forge_std.exists()); + assert!(forge_std_mod.exists()); + + let submods = read_string(&git_mod_file); + assert!(submods.contains("https://github.com/foundry-rs/forge-std")); + }; + + let remove = |cmd: &mut TestCommand, target: &str| { + // TODO: flaky behavior with URL, sometimes it is None, sometimes it is Some("https://github.com/lib/forge-std") + cmd.forge_fuse().args(["remove", "--force", target]).assert_success().stdout_eq(str![[ + r#" +Removing 'forge-std' in [..], (url: [..], tag: None) + +"# + ]]); + + assert!(!forge_std.exists()); + assert!(!forge_std_mod.exists()); + let submods = read_string(&git_mod_file); + assert!(!submods.contains("https://github.com/foundry-rs/forge-std")); + }; + + install(&mut cmd); + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std, DepIdentifier::Tag { .. })); + remove(&mut cmd, "forge-std"); + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")); + assert!(forge_std.is_none()); + + // install again and remove via relative path + install(&mut cmd); + remove(&mut cmd, "lib/forge-std"); +}); + +// test to check we can run `forge install` in an empty dir +forgetest!(can_install_empty, |prj, cmd| { + // create + cmd.git_init(); + cmd.forge_fuse().args(["install"]); + cmd.assert_empty_stdout(); + + // create initial commit + fs::write(prj.root().join("README.md"), "Initial commit").unwrap(); + + cmd.git_add(); + cmd.git_commit("Initial commit"); + + cmd.forge_fuse().args(["install"]); + cmd.assert_empty_stdout(); +}); + +// test to check that package can be reinstalled after manually removing the directory +forgetest!(can_reinstall_after_manual_remove, |prj, cmd| { + cmd.git_init(); + + let libs = prj.root().join("lib"); + let git_mod = prj.root().join(".git/modules/lib"); + let git_mod_file = prj.root().join(".gitmodules"); + + let forge_std = libs.join("forge-std"); + let forge_std_mod = git_mod.join("forge-std"); + + let install = |cmd: &mut TestCommand| { + cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq( + str![[r#" +Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) + Installed forge-std tag=[..]"#]], + ); + + assert!(forge_std.exists()); + assert!(forge_std_mod.exists()); + + let submods = read_string(&git_mod_file); + assert!(submods.contains("https://github.com/foundry-rs/forge-std")); + }; + + install(&mut cmd); + let forge_std_lock = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std_lock, DepIdentifier::Tag { .. })); + fs::remove_dir_all(forge_std.clone()).expect("Failed to remove forge-std"); + + // install again with tag + install(&mut cmd); + let forge_std_lock = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std_lock, DepIdentifier::Tag { .. })); +}); + +// test that we can repeatedly install the same dependency without changes +forgetest!(can_install_repeatedly, |_prj, cmd| { + cmd.git_init(); + + cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]); + for _ in 0..3 { + cmd.assert_success(); + } +}); + +// test that by default we install the latest semver release tag +// +forgetest!(can_install_latest_release_tag, |prj, cmd| { + cmd.git_init(); + cmd.forge_fuse().args(["install", "openzeppelin/openzeppelin-contracts"]); + cmd.assert_success(); + + let dep = prj.paths().libraries[0].join("openzeppelin-contracts"); + assert!(dep.exists()); + + let oz_lock = lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + assert!(matches!(oz_lock, DepIdentifier::Tag { .. })); + + // the latest release at the time this test was written + let version: Version = "4.8.0".parse().unwrap(); + let out = Command::new("git").current_dir(&dep).args(["describe", "--tags"]).output().unwrap(); + let tag = String::from_utf8_lossy(&out.stdout); + let current: Version = tag.as_ref().trim_start_matches('v').trim().parse().unwrap(); + + assert!(current >= version); +}); + +forgetest!(can_update_and_retain_tag_revs, |prj, cmd| { + cmd.git_init(); + + // Installs oz at release tag + cmd.forge_fuse() + .args(["install", "openzeppelin/openzeppelin-contracts@v5.1.0"]) + .assert_success(); + + // Install solady pinned to rev i.e https://github.com/Vectorized/solady/commit/513f581675374706dbe947284d6b12d19ce35a2a + cmd.forge_fuse().args(["install", "vectorized/solady@513f581"]).assert_success(); + + let out = cmd.git_submodule_status(); + let status = String::from_utf8_lossy(&out.stdout); + let oz_init = lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + let solady_init = lockfile_get(prj.root(), &PathBuf::from("lib/solady")).unwrap(); + assert_eq!(oz_init.name(), "v5.1.0"); + assert_eq!(solady_init.rev(), "513f581"); + let submodules_init: Submodules = status.parse().unwrap(); + + cmd.forge_fuse().arg("update").assert_success(); + + let out = cmd.git_submodule_status(); + let status = String::from_utf8_lossy(&out.stdout); + let submodules_update: Submodules = status.parse().unwrap(); + assert_eq!(submodules_init, submodules_update); + + let oz_update = lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + let solady_update = lockfile_get(prj.root(), &PathBuf::from("lib/solady")).unwrap(); + assert_eq!(oz_init, oz_update); + assert_eq!(solady_init, solady_update); +}); + +forgetest!(can_override_tag_in_update, |prj, cmd| { + cmd.git_init(); + + // Installs oz at release tag + cmd.forge_fuse() + .args(["install", "openzeppelin/openzeppelin-contracts@v5.0.2"]) + .assert_success(); + + cmd.forge_fuse().args(["install", "vectorized/solady@513f581"]).assert_success(); + + let out = cmd.git_submodule_status(); + let status = String::from_utf8_lossy(&out.stdout); + + let submodules_init: Submodules = status.parse().unwrap(); + + let oz_init_lock = + lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + assert_eq!(oz_init_lock.name(), "v5.0.2"); + let solady_init_lock = lockfile_get(prj.root(), &PathBuf::from("lib/solady")).unwrap(); + assert_eq!(solady_init_lock.rev(), "513f581"); + + // Update oz to a different release tag + cmd.forge_fuse() + .args(["update", "openzeppelin/openzeppelin-contracts@v5.1.0"]) + .assert_success(); + + let out = cmd.git_submodule_status(); + let status = String::from_utf8_lossy(&out.stdout); + + let submodules_update: Submodules = status.parse().unwrap(); + + assert_ne!(submodules_init.0[0], submodules_update.0[0]); + assert_eq!(submodules_init.0[1], submodules_update.0[1]); + + let oz_update_lock = + lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + let solady_update_lock = lockfile_get(prj.root(), &PathBuf::from("lib/solady")).unwrap(); + + assert_ne!(oz_init_lock, oz_update_lock); + assert_eq!(oz_update_lock.name(), "v5.1.0"); + assert_eq!(solady_init_lock, solady_update_lock); +}); + +// Ref: https://github.com/foundry-rs/foundry/pull/9522#pullrequestreview-2494431518 +forgetest!(should_not_update_tagged_deps, |prj, cmd| { + cmd.git_init(); + + // Installs oz at release tag + cmd.forge_fuse() + .args(["install", "openzeppelin/openzeppelin-contracts@tag=v4.9.4"]) + .assert_success(); + + let out = cmd.git_submodule_status(); + let status = String::from_utf8_lossy(&out.stdout); + let submodules_init: Submodules = status.parse().unwrap(); + + let oz_init = lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + + cmd.forge_fuse().arg("update").assert_success(); + + let out = cmd.git_submodule_status(); + let status = String::from_utf8_lossy(&out.stdout); + let submodules_update: Submodules = status.parse().unwrap(); + + assert_eq!(submodules_init, submodules_update); + + let oz_update = lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + + assert_eq!(oz_init, oz_update); + // Check that halmos-cheatcodes dep is not added to oz deps + let halmos_path = prj.paths().libraries[0].join("openzeppelin-contracts/lib/halmos-cheatcodes"); + + assert!(!halmos_path.exists()); +}); + +forgetest!(can_remove_dep_from_foundry_lock, |prj, cmd| { + cmd.git_init(); + + cmd.forge_fuse() + .args(["install", "openzeppelin/openzeppelin-contracts@tag=v4.9.4"]) + .assert_success(); + + cmd.forge_fuse().args(["install", "vectorized/solady@513f581"]).assert_success(); + cmd.forge_fuse().args(["remove", "openzeppelin-contracts", "--force"]).assert_success(); + + let mut lock = Lockfile::new(prj.root()); + + lock.read().unwrap(); + + assert!(lock.get(&PathBuf::from("lib/openzeppelin-contracts")).is_none()); +}); + +forgetest!( + #[cfg_attr(windows, ignore = "weird git fail")] + can_sync_foundry_lock, + |prj, cmd| { + cmd.git_init(); + + cmd.forge_fuse().args(["install", "foundry-rs/forge-std@master"]).assert_success(); + + cmd.forge_fuse().args(["install", "vectorized/solady"]).assert_success(); + + fs::remove_file(prj.root().join("foundry.lock")).unwrap(); + + // sync submodules and write foundry.lock + cmd.forge_fuse().arg("install").assert_success(); + + let mut lock = forge::Lockfile::new(prj.root()); + lock.read().unwrap(); + + assert!(matches!( + lock.get(&PathBuf::from("lib/forge-std")).unwrap(), + &DepIdentifier::Branch { .. } + )); + assert!(matches!( + lock.get(&PathBuf::from("lib/solady")).unwrap(), + &DepIdentifier::Rev { .. } + )); + } +); + +// Tests that forge update doesn't break a working dependency by recursively updating nested +// dependencies +forgetest!( + #[cfg_attr(windows, ignore = "weird git fail")] + can_update_library_with_outdated_nested_dependency, + |prj, cmd| { + cmd.git_init(); + + let libs = prj.root().join("lib"); + let git_mod = prj.root().join(".git/modules/lib"); + let git_mod_file = prj.root().join(".gitmodules"); + + // get paths to check inside install fn + let package = libs.join("forge-5980-test"); + let package_mod = git_mod.join("forge-5980-test"); + + // install main dependency + cmd.forge_fuse() + .args(["install", "evalir/forge-5980-test"]) + .assert_success() + .stdout_eq(str![[r#" +Installing forge-5980-test in [..] (url: Some("https://github.com/evalir/forge-5980-test"), tag: None) + Installed forge-5980-test + +"#]]); + + // assert paths exist + assert!(package.exists()); + assert!(package_mod.exists()); + + let submods = read_string(git_mod_file); + assert!(submods.contains("https://github.com/evalir/forge-5980-test")); + + // try to update the top-level dependency; there should be no update for this dependency, + // but its sub-dependency has upstream (breaking) changes; forge should not attempt to + // update the sub-dependency + cmd.forge_fuse().args(["update", "lib/forge-5980-test"]).assert_empty_stdout(); + + // add explicit remappings for test file + let config = Config { + remappings: vec![ + Remapping::from_str("forge-5980-test/=lib/forge-5980-test/src/").unwrap().into(), + // explicit remapping for sub-dependendy seems necessary for some reason + Remapping::from_str( + "forge-5980-test-dep/=lib/forge-5980-test/lib/forge-5980-test-dep/src/", + ) + .unwrap() + .into(), + ], + ..Default::default() + }; + prj.write_config(config); + + // create test file that uses the top-level dependency; if the sub-dependency is updated, + // compilation will fail + prj.add_source( + "CounterCopy", + r#" +import "forge-5980-test/Counter.sol"; +contract CounterCopy is Counter { +} + "#, + ) + .unwrap(); + + // build and check output + cmd.forge_fuse().arg("build").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]); + } +); + +#[tokio::test] +async fn uni_v4_core_sync_foundry_lock() { + let (prj, mut cmd) = + ExtTester::new("Uniswap", "v4-core", "e50237c43811bd9b526eff40f26772152a42daba") + .setup_forge_prj(); + + assert!(!prj.root().join(FOUNDRY_LOCK).exists()); + + let git = Git::new(prj.root()); + + let submodules = git.submodules().unwrap(); + + let submod_forge_std = + submodules.into_iter().find(|s| s.path() == &PathBuf::from("lib/forge-std")).unwrap(); + let submod_oz = submodules + .into_iter() + .find(|s| s.path() == &PathBuf::from("lib/openzeppelin-contracts")) + .unwrap(); + let submod_solmate = + submodules.into_iter().find(|s| s.path() == &PathBuf::from("lib/solmate")).unwrap(); + + cmd.arg("install").assert_success(); + + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std, DepIdentifier::Rev { .. })); + assert_eq!(forge_std.rev(), submod_forge_std.rev()); + let solmate = lockfile_get(prj.root(), &PathBuf::from("lib/solmate")).unwrap(); + assert!(matches!(solmate, DepIdentifier::Rev { .. })); + assert_eq!(solmate.rev(), submod_solmate.rev()); + let oz = lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + assert!(matches!(oz, DepIdentifier::Rev { .. })); + assert_eq!(oz.rev(), submod_oz.rev()); + + // Commit the lockfile + git.add(&PathBuf::from(FOUNDRY_LOCK)).unwrap(); + git.commit("Foundry lock").unwrap(); + + // Try update. Nothing should get updated everything is pinned tag/rev. + cmd.forge_fuse().arg("update").assert_success(); + + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std, DepIdentifier::Rev { .. })); + assert_eq!(forge_std.rev(), submod_forge_std.rev()); + let solmate = lockfile_get(prj.root(), &PathBuf::from("lib/solmate")).unwrap(); + assert!(matches!(solmate, DepIdentifier::Rev { .. })); + assert_eq!(solmate.rev(), submod_solmate.rev()); + let oz = lockfile_get(prj.root(), &PathBuf::from("lib/openzeppelin-contracts")).unwrap(); + assert!(matches!(oz, DepIdentifier::Rev { .. })); + assert_eq!(oz.rev(), submod_oz.rev()); +} + +#[tokio::test] +async fn oz_contracts_sync_foundry_lock() { + let (prj, mut cmd) = ExtTester::new( + "OpenZeppelin", + "openzeppelin-contracts", + "840c974028316f3c8172c1b8e5ed67ad95e255ca", + ) + .setup_forge_prj(); + + assert!(!prj.root().join(FOUNDRY_LOCK).exists()); + + let git = Git::new(prj.root()); + + let submodules = git.submodules().unwrap(); + + let submod_forge_std = + submodules.into_iter().find(|s| s.path() == &PathBuf::from("lib/forge-std")).unwrap(); + let submod_erc4626_tests = + submodules.into_iter().find(|s| s.path() == &PathBuf::from("lib/erc4626-tests")).unwrap(); + let submod_halmos = submodules + .into_iter() + .find(|s| s.path() == &PathBuf::from("lib/halmos-cheatcodes")) + .unwrap(); + + cmd.arg("install").assert_success(); + + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std, DepIdentifier::Branch { .. })); + assert_eq!(forge_std.rev(), submod_forge_std.rev()); + assert_eq!(forge_std.name(), "v1"); + let erc4626_tests = lockfile_get(prj.root(), &PathBuf::from("lib/erc4626-tests")).unwrap(); + assert!(matches!(erc4626_tests, DepIdentifier::Rev { .. })); + assert_eq!(erc4626_tests.rev(), submod_erc4626_tests.rev()); + let halmos = lockfile_get(prj.root(), &PathBuf::from("lib/halmos-cheatcodes")).unwrap(); + assert!(matches!(halmos, DepIdentifier::Rev { .. })); + assert_eq!(halmos.rev(), submod_halmos.rev()); + + // Commit the lockfile + git.add(&PathBuf::from(FOUNDRY_LOCK)).unwrap(); + git.commit("Foundry lock").unwrap(); + + // Try update. forge-std should get updated, rest should remain the same. + cmd.forge_fuse().arg("update").assert_success(); + + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std, DepIdentifier::Branch { .. })); + // assert_eq!(forge_std.rev(), submod_forge_std.rev()); // This can fail, as forge-std will get + // updated to the latest commit on master. + assert_eq!(forge_std.name(), "v1"); // But it stays locked on the same master + let erc4626_tests = lockfile_get(prj.root(), &PathBuf::from("lib/erc4626-tests")).unwrap(); + assert!(matches!(erc4626_tests, DepIdentifier::Rev { .. })); + assert_eq!(erc4626_tests.rev(), submod_erc4626_tests.rev()); + let halmos = lockfile_get(prj.root(), &PathBuf::from("lib/halmos-cheatcodes")).unwrap(); + assert!(matches!(halmos, DepIdentifier::Rev { .. })); + assert_eq!(halmos.rev(), submod_halmos.rev()); +} + +#[tokio::test] +async fn correctly_sync_dep_with_multiple_version() { + let (prj, mut cmd) = ExtTester::new( + "yash-atreya", + "sync-lockfile-multi-version-dep", + "1ca47e73a168e54f8f7761862dbd0c603856c5c8", + ) + .setup_forge_prj(); + + assert!(!prj.root().join(FOUNDRY_LOCK).exists()); + + let git = Git::new(prj.root()); + + let submodules = git.submodules().unwrap(); + let submod_forge_std = + submodules.into_iter().find(|s| s.path() == &PathBuf::from("lib/forge-std")).unwrap(); + let submod_solady = + submodules.into_iter().find(|s| s.path() == &PathBuf::from("lib/solady")).unwrap(); + let submod_solday_v_245 = + submodules.into_iter().find(|s| s.path() == &PathBuf::from("lib/solady-v0.0.245")).unwrap(); + + cmd.arg("install").assert_success(); + + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert!(matches!(forge_std, DepIdentifier::Rev { .. })); + assert_eq!(forge_std.rev(), submod_forge_std.rev()); + + let solady = lockfile_get(prj.root(), &PathBuf::from("lib/solady")).unwrap(); + assert!(matches!(solady, DepIdentifier::Rev { .. })); + assert_eq!(solady.rev(), submod_solady.rev()); + + let solday_v_245 = lockfile_get(prj.root(), &PathBuf::from("lib/solady-v0.0.245")).unwrap(); + assert!(matches!(solday_v_245, DepIdentifier::Rev { .. })); + assert_eq!(solday_v_245.rev(), submod_solday_v_245.rev()); +} diff --git a/crates/forge/tests/cli/main.rs b/crates/forge/tests/cli/main.rs index 40a8c9f8b5d5a..4a59aa2b51e63 100644 --- a/crates/forge/tests/cli/main.rs +++ b/crates/forge/tests/cli/main.rs @@ -19,6 +19,7 @@ mod eip712; mod failure_assertions; mod geiger; mod inline_config; +mod install; mod lint; mod multi_script; mod script; diff --git a/crates/test-utils/src/util.rs b/crates/test-utils/src/util.rs index 9b59f50d1e848..c1db8e064798a 100644 --- a/crates/test-utils/src/util.rs +++ b/crates/test-utils/src/util.rs @@ -29,7 +29,7 @@ use std::{ static CURRENT_DIR_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); /// The commit of forge-std to use. -const FORGE_STD_REVISION: &str = include_str!("../../../testdata/forge-std-rev"); +pub const FORGE_STD_REVISION: &str = include_str!("../../../testdata/forge-std-rev"); /// Stores whether `stdout` is a tty / terminal. pub static IS_TTY: LazyLock = LazyLock::new(|| std::io::stdout().is_terminal()); @@ -138,15 +138,8 @@ impl ExtTester { self } - /// Runs the test. - pub fn run(&self) { - // Skip fork tests if the RPC url is not set. - if self.fork_block.is_some() && std::env::var_os("ETH_RPC_URL").is_none() { - eprintln!("ETH_RPC_URL is not set; skipping"); - return; - } - - let (prj, mut test_cmd) = setup_forge(self.name, self.style.clone()); + pub fn setup_forge_prj(&self) -> (TestProject, TestCommand) { + let (prj, test_cmd) = setup_forge(self.name, self.style.clone()); // Wipe the default structure. prj.wipe(); @@ -178,7 +171,10 @@ impl ExtTester { } } - // Run installation command. + (prj, test_cmd) + } + + pub fn run_install_commands(&self, root: &str) { for install_command in &self.install_commands { let mut install_cmd = Command::new(&install_command[0]); install_cmd.args(&install_command[1..]).current_dir(root); @@ -195,6 +191,20 @@ impl ExtTester { } } } + } + + /// Runs the test. + pub fn run(&self) { + // Skip fork tests if the RPC url is not set. + if self.fork_block.is_some() && std::env::var_os("ETH_RPC_URL").is_none() { + eprintln!("ETH_RPC_URL is not set; skipping"); + return; + } + + let (prj, mut test_cmd) = self.setup_forge_prj(); + + // Run installation command. + self.run_install_commands(prj.root().to_str().unwrap()); // Run the tests. test_cmd.arg("test"); @@ -886,6 +896,14 @@ impl TestCommand { output.success(); } + /// Runs `git submodule status` inside the project's dir + #[track_caller] + pub fn git_submodule_status(&self) -> Output { + let mut cmd = Command::new("git"); + cmd.arg("submodule").arg("status").current_dir(self.project.root()); + cmd.output().unwrap() + } + /// Runs `git add .` inside the project's dir #[track_caller] pub fn git_add(&self) {