Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(forge): pin tags/revs for deps #9522

Open
wants to merge 54 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
3bcf1b9
parse submodule status output
yash-atreya Dec 6, 2024
a3a01ec
feat(`forge`): save submodules info on install
yash-atreya Dec 9, 2024
cd075f2
re-checkout to tag/rev after forge update
yash-atreya Dec 9, 2024
917cc13
clippy
yash-atreya Dec 9, 2024
e7fb768
fmt
yash-atreya Dec 9, 2024
8e4b543
fix
yash-atreya Dec 9, 2024
53b362d
fix
yash-atreya Dec 10, 2024
f666e8c
test
yash-atreya Dec 10, 2024
0ea584d
fix
yash-atreya Dec 10, 2024
cf88ef8
override using forge update
yash-atreya Dec 10, 2024
2a55c67
nit
yash-atreya Dec 10, 2024
19194c0
nit
yash-atreya Dec 10, 2024
7f6d026
fix: update only untagged deps
yash-atreya Dec 11, 2024
ed3ccfe
allow overrides
yash-atreya Dec 11, 2024
f893917
clippy
yash-atreya Dec 11, 2024
dd072fd
remove + rename to foundry.lock
yash-atreya Dec 11, 2024
ed14a48
nit
yash-atreya Dec 11, 2024
90d92bb
fix: sync foundry.lock on install
yash-atreya Dec 11, 2024
5640bab
sync foundry lock using forge install
yash-atreya Dec 11, 2024
7d9ce8d
fix: read_and_sync_foundry_lock
yash-atreya Dec 17, 2024
92454f8
fix
yash-atreya Dec 17, 2024
46ce492
fix test
yash-atreya Dec 17, 2024
9ae88d6
fix
yash-atreya Dec 17, 2024
fdb1b32
fix
yash-atreya Dec 17, 2024
00a887c
fix
yash-atreya Dec 17, 2024
ac922fa
Merge branch 'master' into yash/fix-forge-update
yash-atreya Jan 6, 2025
07ce536
Merge branch 'master' into yash/fix-forge-update
yash-atreya Jan 10, 2025
53eed1f
Merge branch 'master' into yash/fix-forge-update
grandizzy Jan 14, 2025
3a7e7eb
Do not run can_sync_foundry_lock test on win (fails on master branch …
grandizzy Jan 14, 2025
b7954c3
Merge branch 'master' into yash/fix-forge-update
yash-atreya Jan 29, 2025
8276b42
feat: introduce `LockFile` type, use it in forge install and forge re…
yash-atreya Jan 29, 2025
2f82d39
fix: account for clean lib/ dir while syncing lockfile
yash-atreya Jan 29, 2025
c4a13fd
fix: integrate lockfile into update
yash-atreya Jan 29, 2025
1859e4d
clippy
yash-atreya Jan 29, 2025
97fba00
fix
yash-atreya Jan 29, 2025
090da39
fix
yash-atreya Jan 29, 2025
7787895
feat(`forge`): introduces a `Lockfile` type (#9781)
yash-atreya Jan 29, 2025
ddf425d
clean up forge update
yash-atreya Jan 29, 2025
1cd019a
Merge branch 'yash/lockfile' into yash/fix-forge-update
yash-atreya Jan 29, 2025
5fb8861
nits
yash-atreya Jan 29, 2025
6fc5850
nit
yash-atreya Jan 30, 2025
a55709c
fix: update branch rev in lockfile and print updates
yash-atreya Jan 30, 2025
c42c915
fix
yash-atreya Jan 30, 2025
9b93c00
clippy
yash-atreya Jan 30, 2025
94b5238
nit
yash-atreya Jan 30, 2025
29d846a
fix
yash-atreya Jan 30, 2025
f25c0da
assert foundry lock in tests
yash-atreya Jan 30, 2025
883d048
nit
yash-atreya Jan 30, 2025
94edb91
refac ExtTester and test uni v4 foundry lock sync
yash-atreya Jan 30, 2025
cb7e434
oz sync test
yash-atreya Jan 30, 2025
8fc72e1
fix: run sync after submodule update on install
yash-atreya Jan 30, 2025
4f91ecc
fix: tag_for_commit should return earliest tag that contains commit +…
yash-atreya Jan 30, 2025
50e4a4a
Merge branch 'master' into yash/fix-forge-update
yash-atreya Jan 30, 2025
4c87a7a
fix: write lockfile after git succeeds
yash-atreya Feb 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions crates/cli/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -375,10 +376,21 @@ impl<'a> Git<'a> {
.map(drop)
}

pub fn checkout_at(self, tag: impl AsRef<OsStr>, 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))
}

#[allow(clippy::should_implement_trait)] // this is not std::ops::Add clippy
pub fn add<I, S>(self, paths: I) -> Result<()>
where
Expand Down Expand Up @@ -452,6 +464,26 @@ impl<'a> Git<'a> {
.map(|stdout| !stdout.is_empty())
}

pub fn has_tag(self, tag: impl AsRef<OsStr>, at: &Path) -> Result<bool> {
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<OsStr>, at: &Path) -> Result<bool> {
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<OsStr>, at: &Path) -> Result<String> {
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(())
Expand Down Expand Up @@ -480,6 +512,21 @@ ignore them in the `.gitignore` file, or run this command again with the `--no-c
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<Option<String>> {
self.cmd_at(at)
.args(["tag", "--contains"])
.arg(rev)
.get_stdout_lossy()
.map(|stdout| stdout.lines().next().map(str::to_string))
}

pub fn has_missing_dependencies<I, S>(self, paths: I) -> Result<bool>
where
I: IntoIterator<Item = S>,
Expand Down Expand Up @@ -561,6 +608,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<String> {
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<Submodules> {
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);
Expand Down Expand Up @@ -589,13 +649,113 @@ 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
}
}

impl FromStr for Submodule {
type Err = eyre::Report;

fn from_str(s: &str) -> Result<Self> {
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<Submodule>);

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<Self> {
let subs = s.lines().map(str::parse).collect::<Result<Vec<Submodule>>>()?;
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::*;
use foundry_common::fs;
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");
Expand Down
64 changes: 56 additions & 8 deletions crates/forge/bin/cmd/install.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use clap::{Parser, ValueHint};
use eyre::{Context, Result};
use forge::{DepIdentifier, Lockfile, FOUNDRY_LOCK};
use foundry_cli::{
opts::Dependency,
utils::{CommandUtils, Git, LoadConfig},
Expand Down Expand Up @@ -116,7 +117,12 @@ 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);
}

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)) {
Expand All @@ -136,6 +142,8 @@ impl DependencyInstallOpts {
}
}

let out_of_sync_deps = lockfile.sync()?;

fs::create_dir_all(&libs)?;

let installer = Installer { git, no_commit };
Expand All @@ -154,6 +162,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 {
Expand All @@ -162,40 +171,78 @@ 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());
}
// 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 new_insertion ||
out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty()) ||
!lockfile.exists()
{
lockfile.write()?;
}

// 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(dep_id) = &dep_id {
msg.push_str("\n\n");
msg.push_str(dep_id.to_string().as_str());
} else {
msg.push_str("\n\n");
msg.push_str(tag);
}
}

if !lockfile.is_empty() {
git.root(&config.root).add(Some(FOUNDRY_LOCK))?;
}
git.commit(&msg)?;
}
}

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(' ');
msg.push_str(dep_id.to_string().as_str());
} else {
msg.push(' ');
msg.push_str(tag.as_str());
}
}
sh_println!("{msg}")?;
}
Expand All @@ -205,6 +252,7 @@ impl DependencyInstallOpts {
config.libs.push(install_lib_dir.to_path_buf());
config.update_libs()?;
}

Ok(())
}
}
Expand Down
12 changes: 10 additions & 2 deletions crates/forge/bin/cmd/remove.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use clap::{Parser, ValueHint};
use eyre::Result;
use forge::Lockfile;
use foundry_cli::{
opts::Dependency,
utils::{Git, LoadConfig},
Expand Down Expand Up @@ -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()?;

// 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(())
}
}
Loading