diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 6bf8c5b5d162..96a081e803c9 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -14,6 +14,7 @@ use std::{ future::Future, path::{Path, PathBuf}, process::{Command, Output, Stdio}, + str::FromStr, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tracing_subscriber::prelude::*; @@ -375,6 +376,10 @@ 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) } @@ -452,6 +457,22 @@ 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 ensure_clean(self) -> Result<()> { if self.is_clean()? { Ok(()) @@ -480,6 +501,15 @@ ignore them in the `.gitignore` file, or run this command again with the `--no-c self.cmd().arg("tag").get_stdout_lossy() } + /// Returns the latest tag for a given commit. + 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().last().map(str::to_string)) + } + pub fn has_missing_dependencies(self, paths: I) -> Result where I: IntoIterator, @@ -561,6 +591,19 @@ ignore them in the `.gitignore` file, or run this command again with the `--no-c self.cmd().stderr(self.stderr()).args(["submodule", "init"]).exec().map(drop) } + 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(r"HEAD branch: (.*)")?; + 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 cmd(self) -> Command { let mut cmd = Self::cmd_no_root(); cmd.current_dir(self.root); @@ -589,6 +632,120 @@ ignore them in the `.gitignore` file, or run this command again with the `--no-c } } +/// 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 + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub enum TagType { + Branch(String), + Tag(String), + Rev(String), +} + +impl TagType { + /// Resolves the [TagType] 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 { + // Get the tags for the submodule + if git.has_tag(s, lib_path)? { + return Ok(Self::Tag(String::from(s))); + } + + if git.has_branch(s, lib_path)? { + return Ok(Self::Branch(String::from(s))); + } + + if git.has_rev(s, lib_path)? { + return Ok(Self::Rev(String::from(s))); + } + + Err(eyre::eyre!("Could not resolve tag type for submodule at path {}", lib_path.display())) + } + + /// Returns the raw string representation of the [TagType]. i.e without the type prefix. + pub fn raw_string(&self) -> &String { + match self { + Self::Branch(s) | Self::Tag(s) | Self::Rev(s) => s, + } + } +} + +impl std::fmt::Display for TagType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Branch(s) => write!(f, "branch={s}"), + Self::Tag(s) => write!(f, "tag={s}"), + Self::Rev(s) => write!(f, "rev={s}"), + } + } +} + +impl FromStr for Submodule { + type Err = eyre::Report; + + fn from_str(s: &str) -> Result { + let re = regex::Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$")?; + + 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::*; @@ -596,6 +753,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"); diff --git a/crates/forge/bin/cmd/install.rs b/crates/forge/bin/cmd/install.rs index 3543caeea51b..da8224296c94 100644 --- a/crates/forge/bin/cmd/install.rs +++ b/crates/forge/bin/cmd/install.rs @@ -1,14 +1,16 @@ +use alloy_primitives::map::HashMap; use clap::{Parser, ValueHint}; use eyre::{Context, Result}; use foundry_cli::{ opts::Dependency, - utils::{CommandUtils, Git, LoadConfig}, + utils::{CommandUtils, Git, LoadConfig, TagType}, }; use foundry_common::fs; use foundry_config::{impl_figment_convert_basic, Config}; use regex::Regex; use semver::Version; use std::{ + collections::hash_map::Entry, io::IsTerminal, path::{Path, PathBuf}, str, @@ -19,6 +21,8 @@ use yansi::Paint; static DEPENDENCY_VERSION_TAG_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap()); +pub const FOUNDRY_LOCK: &str = "foundry.lock"; + /// CLI arguments for `forge install`. #[derive(Clone, Debug, Parser)] #[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]... @@ -115,6 +119,13 @@ impl DependencyInstallOpts { let install_lib_dir = config.install_lib_dir(); let libs = git.root.join(install_lib_dir); + let foundry_lock_path = config.root.join(FOUNDRY_LOCK); + + let (mut foundry_lock, out_of_sync) = read_or_generate_foundry_lock( + &foundry_lock_path, + if no_git { None } else { Some(&git) }, + )?; + if dependencies.is_empty() && !self.no_git { // Use the root of the git repository to look for submodules. let root = Git::root_of(git.root)?; @@ -124,6 +135,11 @@ impl DependencyInstallOpts { // recursively fetch all submodules (without fetching latest) git.submodule_update(false, false, false, true, Some(&libs))?; + + if out_of_sync || !foundry_lock_path.exists() { + // write foundry.lock + fs::write_json_file(&foundry_lock_path, &foundry_lock)?; + } } Err(err) => { @@ -153,6 +169,7 @@ impl DependencyInstallOpts { // this tracks the actual installed tag let installed_tag; + let mut tag_type = None; if no_git { installed_tag = installer.install_as_folder(&dep, &path)?; } else { @@ -162,30 +179,51 @@ impl DependencyInstallOpts { installed_tag = installer.install_as_submodule(&dep, &path)?; // 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)? { + tag_type = TagType::resolve_type(&git, &path, tag_or_branch).ok(); + if git.has_branch(tag_or_branch, &path)? { // 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()?; + + // Resolve tag type should be TagType::Branch + tag_type = Some(TagType::Branch(tag_or_branch.clone())); } + trace!(?tag_type, ?tag_or_branch, "resolved tag type"); + if let Some(tag_type) = &tag_type { + foundry_lock.insert(rel_path.to_path_buf(), tag_type.clone()); + } // update .gitmodules which is at the root of the repo, // not necessarily at the root of the current Foundry project let root = Git::root_of(git.root)?; git.root(&root).add(Some(".gitmodules"))?; } + if !foundry_lock.is_empty() { + fs::write_json_file(&foundry_lock_path, &foundry_lock)?; + } + // commit the installation if !no_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(tag_type) = &tag_type { + msg.push_str("\n\n"); + msg.push_str(tag_type.to_string().as_str()); + } else { + msg.push_str("\n\n"); + msg.push_str(tag); + } + } + + if !foundry_lock.is_empty() { + git.root(&config.root).add(Some(FOUNDRY_LOCK))?; } git.commit(&msg)?; } @@ -193,8 +231,13 @@ 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(tag_type) = tag_type { + msg.push(' '); + msg.push_str(tag_type.to_string().as_str()); + } else { + msg.push(' '); + msg.push_str(tag.as_str()); + } } sh_println!("{msg}")?; } @@ -204,6 +247,7 @@ impl DependencyInstallOpts { config.libs.push(install_lib_dir.to_path_buf()); config.update_libs()?; } + Ok(()) } } @@ -511,6 +555,55 @@ fn match_yn(input: String) -> bool { matches!(s.as_str(), "" | "y" | "yes") } +/// Reads and syncs the foundry.lock file with the current state of the submodules. +/// +/// Takes the absolute path to the foundry.lock file and an optional Git instance. +/// +/// Returns a tuple of the foundry.lock HashMap. +pub fn read_or_generate_foundry_lock( + path: &Path, + git: Option<&Git<'_>>, +) -> Result<(HashMap, bool)> { + let mut lock: HashMap = if !path.exists() { + HashMap::default() + } else { + let str_lock = fs::read_to_string(path)?; + let lock: HashMap = serde_json::from_str(&str_lock).unwrap_or_default(); + lock + }; + + trace!(?lock, "read foundry.lock"); + + let mut out_of_sync = false; + + if git.is_none() { + return Ok((lock, out_of_sync)) + } + + let git = git.unwrap(); + // Check if foundry.lock is in sync with the current state of the submodules + let submodules = git.submodules()?; + for sub in &submodules { + let rel_path = sub.path(); + let rev = sub.rev(); + + let tag = if let Ok(Some(tag)) = git.tag_for_commit(rev, &git.root.join(rel_path)) { + TagType::Tag(tag) + } else { + TagType::Rev(rev.to_string()) + }; + + let entry = lock.entry(rel_path.to_path_buf()); + + if let Entry::Vacant(e) = entry { + out_of_sync = true; + e.insert(tag); + } + } + + Ok((lock, out_of_sync)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forge/bin/cmd/remove.rs b/crates/forge/bin/cmd/remove.rs index da2f8b251119..d00650fa8fca 100644 --- a/crates/forge/bin/cmd/remove.rs +++ b/crates/forge/bin/cmd/remove.rs @@ -4,9 +4,12 @@ use foundry_cli::{ opts::Dependency, utils::{Git, LoadConfig}, }; +use foundry_common::fs; use foundry_config::impl_figment_convert_basic; use std::path::PathBuf; +use super::install::FOUNDRY_LOCK; + /// CLI arguments for `forge remove`. #[derive(Clone, Debug, Parser)] pub struct RemoveArgs { @@ -30,18 +33,26 @@ impl_figment_convert_basic!(RemoveArgs); impl RemoveArgs { pub fn run(self) -> Result<()> { let config = self.try_load_config_emit_warnings()?; - 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 foundry_lock_path = config.root.join(FOUNDRY_LOCK); + let (mut foundry_lock, _) = + crate::cmd::install::read_or_generate_foundry_lock(&foundry_lock_path, Some(&git))?; + // 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 _ = foundry_lock.remove(path); std::fs::remove_dir_all(git_modules.join(path))?; } + fs::write_json_file(&foundry_lock_path, &foundry_lock)?; + Ok(()) } } diff --git a/crates/forge/bin/cmd/update.rs b/crates/forge/bin/cmd/update.rs index c61b03d7a089..389b0e039ed7 100644 --- a/crates/forge/bin/cmd/update.rs +++ b/crates/forge/bin/cmd/update.rs @@ -1,12 +1,16 @@ +use alloy_primitives::map::HashMap; use clap::{Parser, ValueHint}; use eyre::{Context, Result}; use foundry_cli::{ opts::Dependency, - utils::{Git, LoadConfig}, + utils::{Git, LoadConfig, Submodules, TagType}, }; +use foundry_common::fs; use foundry_config::{impl_figment_convert_basic, Config}; use std::path::PathBuf; +use super::install::FOUNDRY_LOCK; + /// CLI arguments for `forge update`. #[derive(Clone, Debug, Parser)] pub struct UpdateArgs { @@ -33,29 +37,144 @@ impl_figment_convert_basic!(UpdateArgs); impl UpdateArgs { pub fn run(self) -> Result<()> { let config = self.try_load_config_emit_warnings()?; - 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" -> TagType::Tag("v0.1.0") + let git = Git::new(&root); + let foundry_lock_path = root.join(FOUNDRY_LOCK); + let (mut foundry_lock, out_of_sync) = + crate::cmd::install::read_or_generate_foundry_lock(&foundry_lock_path, Some(&git))?; + if out_of_sync { + fs::write_json_file(&foundry_lock_path, &foundry_lock)?; + } + + let prev_len = foundry_lock.len(); + + // Mapping of relative path of dependency to its override tag + let mut overrides: HashMap = HashMap::default(); + // update the submodules' tags if any overrides are present + 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(tag_type) = TagType::resolve_type(&git, dep_path, override_tag) { + foundry_lock.insert(rel_path.to_path_buf(), tag_type.clone()); + overrides.insert(rel_path.to_path_buf(), tag_type); + } else { + sh_warn!( + "Could not 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 submodules = git.submodules()?; if self.recursive { // update submodules recursively - git.submodule_update(self.force, true, false, true, paths) + let update_paths = + self.update_paths(&paths, &submodules, &foundry_lock, &dep_overrides); + if let Some(update_paths) = update_paths { + git.submodule_update(self.force, true, false, true, update_paths)?; + } else { + git.submodule_update(self.force, true, false, true, Vec::::new())?; + } } 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 update_paths = + self.update_paths(&paths, &submodules, &foundry_lock, &dep_overrides); + if let Some(update_paths) = update_paths { + // update root submodules + git.submodule_update(self.force, true, false, false, update_paths)?; + } else { + // update all submodules + git.submodule_update(self.force, true, false, false, Vec::::new())?; + // 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")?; + } + } + + // checkout the submodules at the correct tags + for (path, tag) in &foundry_lock { + git.checkout_at(tag.raw_string(), &root.join(path))?; + } + + if prev_len != foundry_lock.len() || !overrides.is_empty() { + fs::write_json_file(&foundry_lock_path, &foundry_lock)?; + } + + Ok(()) + } + + /// Gets the relatives paths to the submodules that need to be updated. + /// If None, it means all submodules need to be updated. + fn update_paths( + &self, + paths: &[PathBuf], + submodules: &Submodules, + foundry_lock: &HashMap, + overrides: &HashMap, + ) -> Option> { + let paths_to_avoid = foundry_lock + .iter() + .filter_map(|(path, tag_type)| { + // Don't update submodules that are pinned to a release tag / rev unless a override + // has been specified. + if let TagType::Tag(_) | TagType::Rev(_) = tag_type { + if !overrides.contains_key(path) { + return Some(path.clone()); + } + } + None + }) + .collect::>(); + + match (paths.is_empty(), paths_to_avoid.is_empty()) { + (true, true) => { + // running `forge update` + None + } + (true, false) => { + // running `forge update` + Some( + submodules + .into_iter() + .filter_map(|s| { + if !paths_to_avoid.contains(s.path()) { + return Some(s.path().to_path_buf()); + } + None + }) + .collect(), + ) + } + (false, true) => { + // running `forge update ` + Some(paths.to_vec()) + } + (false, false) => { + // running `forge update ` + Some(paths.iter().filter(|path| !paths_to_avoid.contains(path)).cloned().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(); 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); @@ -65,7 +184,11 @@ pub fn dependencies_paths(deps: &[Dependency], config: &Config) -> Result<(PathB 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/tests/cli/cmd.rs b/crates/forge/tests/cli/cmd.rs index 62b6de392a8e..7b56da7aa0a9 100644 --- a/crates/forge/tests/cli/cmd.rs +++ b/crates/forge/tests/cli/cmd.rs @@ -1,6 +1,8 @@ //! Contains various tests for checking forge's commands use crate::constants::*; +use alloy_primitives::map::HashMap; +use foundry_cli::utils::{Submodules, TagType}; use foundry_compilers::artifacts::{remappings::Remapping, ConfigurableContractArtifact, Metadata}; use foundry_config::{ parse_with_profile, BasicConfig, Chain, Config, FuzzConfig, InvariantConfig, SolidityErrorCode, @@ -14,7 +16,7 @@ use foundry_test_utils::{ use semver::Version; use std::{ fs, - path::Path, + path::{Path, PathBuf}, process::{Command, Stdio}, str::FromStr, }; @@ -1360,9 +1362,7 @@ forgetest!(can_reinstall_after_manual_remove, |prj, cmd| { .assert_success() .stdout_eq(str![[r#" Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) - Installed forge-std[..] - -"#]]); + Installed forge-std tag=[..]"#]]); assert!(forge_std.exists()); assert!(forge_std_mod.exists()); @@ -1407,6 +1407,124 @@ forgetest!(can_install_latest_release_tag, |prj, cmd| { 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 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); +}); + +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(); + + // 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]); +}); + +// 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(); + + 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); + + // 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"]).assert_success(); + + let lock: HashMap = + foundry_common::fs::read_json_file(&prj.root().join("foundry.lock")).unwrap(); + + assert!(!lock.contains_key(&PathBuf::from("lib/openzeppelin-contracts"))); +}); + +forgetest!(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 lock: HashMap = + foundry_common::fs::read_json_file(&prj.root().join("foundry.lock")).unwrap(); + + assert!(matches!(lock.get(&PathBuf::from("lib/forge-std")).unwrap(), &TagType::Rev(_))); + assert!(matches!(lock.get(&PathBuf::from("lib/solady")).unwrap(), &TagType::Tag(_))); +}); + // Tests that forge update doesn't break a working dependency by recursively updating nested // dependencies forgetest!( diff --git a/crates/test-utils/src/util.rs b/crates/test-utils/src/util.rs index e7410ed607ed..2504fb9b45c5 100644 --- a/crates/test-utils/src/util.rs +++ b/crates/test-utils/src/util.rs @@ -860,6 +860,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) {