Skip to main content

foundry_cli/
lockfile.rs

1//! foundry.lock handler type.
2
3use crate::utils::{Git, SubmoduleCheckoutStatus};
4use alloy_primitives::map::HashMap;
5use eyre::{Context, OptionExt, Result};
6use foundry_common::fs::canonicalize_path;
7use serde::{Deserialize, Serialize};
8use std::{
9    collections::{BTreeMap, hash_map::Entry},
10    path::{Path, PathBuf},
11};
12
13pub const FOUNDRY_LOCK: &str = "foundry.lock";
14
15/// A type alias for a HashMap of dependencies keyed by relative path to the submodule dir.
16pub type DepMap = HashMap<PathBuf, DepIdentifier>;
17
18/// A difference between `foundry.lock` and an installed dependency submodule.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum LockfileMismatch {
21    /// An installed dependency is not recorded in the lockfile.
22    MissingLockEntry { path: PathBuf, actual: Option<String> },
23    /// A lockfile entry does not have a corresponding dependency submodule.
24    MissingSubmodule { path: PathBuf, expected: String },
25    /// The index contains a dependency gitlink without a `.gitmodules` mapping.
26    MissingSubmoduleMapping { path: PathBuf },
27    /// A dependency submodule has not been initialized.
28    Uninitialized { path: PathBuf, expected: Option<String> },
29    /// A dependency submodule has merge conflicts.
30    Conflicted { path: PathBuf },
31    /// The installed revision differs from the locked revision.
32    Revision { path: PathBuf, expected: String, actual: String },
33}
34
35impl LockfileMismatch {
36    /// Returns the dependency path associated with this mismatch.
37    pub fn path(&self) -> &Path {
38        match self {
39            Self::MissingLockEntry { path, .. }
40            | Self::MissingSubmodule { path, .. }
41            | Self::MissingSubmoduleMapping { path }
42            | Self::Uninitialized { path, .. }
43            | Self::Conflicted { path }
44            | Self::Revision { path, .. } => path,
45        }
46    }
47}
48
49impl std::fmt::Display for LockfileMismatch {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::MissingLockEntry { path, actual: Some(actual) } => {
53                write!(f, "{}: missing from foundry.lock (found {actual})", path.display())
54            }
55            Self::MissingLockEntry { path, actual: None } => {
56                write!(f, "{}: missing from foundry.lock", path.display())
57            }
58            Self::MissingSubmodule { path, expected } => write!(
59                f,
60                "{}: dependency submodule is missing (expected {expected})",
61                path.display()
62            ),
63            Self::MissingSubmoduleMapping { path } => {
64                write!(f, "{}: dependency submodule is missing from .gitmodules", path.display())
65            }
66            Self::Uninitialized { path, expected: Some(expected) } => write!(
67                f,
68                "{}: dependency submodule is not initialized (expected {expected})",
69                path.display()
70            ),
71            Self::Uninitialized { path, expected: None } => {
72                write!(f, "{}: dependency submodule is not initialized", path.display())
73            }
74            Self::Conflicted { path } => {
75                write!(f, "{}: dependency submodule has merge conflicts", path.display())
76            }
77            Self::Revision { path, expected, actual } => {
78                write!(f, "{}: expected {expected}, found {actual}", path.display())
79            }
80        }
81    }
82}
83
84/// A lockfile handler that keeps track of the dependencies and their current state.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Lockfile<'a> {
87    /// A map of the dependencies keyed by relative path to the submodule dir.
88    #[serde(flatten)]
89    deps: DepMap,
90    /// This is optional to handle no-git scencarios.
91    #[serde(skip)]
92    git: Option<&'a Git<'a>>,
93    /// Absolute path to the lockfile.
94    #[serde(skip)]
95    lockfile_path: PathBuf,
96}
97
98impl<'a> Lockfile<'a> {
99    /// Create a new [`Lockfile`] instance.
100    ///
101    /// `project_root` is the absolute path to the project root.
102    ///
103    /// You will need to call [`Lockfile::read`] or [`Lockfile::sync`] to load the lockfile.
104    pub fn new(project_root: &Path) -> Self {
105        Self { deps: HashMap::default(), git: None, lockfile_path: project_root.join(FOUNDRY_LOCK) }
106    }
107
108    /// Set the git instance to be used for submodule operations.
109    pub const fn with_git(mut self, git: &'a Git<'_>) -> Self {
110        self.git = Some(git);
111        self
112    }
113
114    /// Sync the foundry.lock file with the current state of `git submodules`.
115    ///
116    /// If the lockfile and git submodules are out of sync, it returns a [`DepMap`] consisting of
117    /// _only_ the out-of-sync dependencies.
118    ///
119    /// This method writes the lockfile to project root if:
120    /// - The lockfile does not exist.
121    /// - The lockfile is out of sync with the git submodules.
122    pub fn sync(&mut self, lib: &Path) -> Result<Option<DepMap>> {
123        match self.read() {
124            Ok(_) => {}
125            Err(e) if !e.to_string().contains("Lockfile not found") => {
126                return Err(e);
127            }
128            _ => {}
129        }
130
131        if let Some(git) = &self.git {
132            let submodules = git.submodules()?;
133
134            if submodules.is_empty() {
135                trace!("No submodules found. Skipping sync.");
136                return Ok(None);
137            }
138
139            let modules_with_branch = git
140                .read_submodules_with_branch(&Git::root_of(git.root)?, lib.file_name().unwrap())?;
141
142            let mut out_of_sync: DepMap = HashMap::default();
143            for sub in &submodules {
144                let rel_path = sub.path();
145                let rev = sub.rev();
146
147                let entry = self.deps.entry(rel_path.clone());
148
149                match entry {
150                    Entry::Occupied(e) if e.get().rev() != rev => {
151                        out_of_sync.insert(rel_path.clone(), e.get().clone());
152                    }
153                    Entry::Vacant(e) => {
154                        // Check if there is branch specified for the submodule at rel_path in
155                        // .gitmodules
156                        let maybe_branch = modules_with_branch.get(rel_path).cloned();
157
158                        trace!(?maybe_branch, submodule = ?rel_path, "submodule branch");
159                        if let Some(branch) = maybe_branch {
160                            let dep_id = DepIdentifier::Branch {
161                                name: branch,
162                                rev: rev.to_string(),
163                                r#override: false,
164                            };
165                            e.insert(dep_id.clone());
166                            out_of_sync.insert(rel_path.clone(), dep_id);
167                            continue;
168                        }
169
170                        let dep_id = DepIdentifier::Rev { rev: rev.to_string(), r#override: false };
171                        trace!(submodule=?rel_path, ?dep_id, "submodule dep_id");
172                        e.insert(dep_id.clone());
173                        out_of_sync.insert(rel_path.clone(), dep_id);
174                    }
175                    _ => {}
176                }
177            }
178
179            return Ok(if out_of_sync.is_empty() { None } else { Some(out_of_sync) });
180        }
181
182        Ok(None)
183    }
184
185    /// Checks whether the lockfile matches dependency submodules without modifying either.
186    pub fn check(&mut self) -> Result<Vec<LockfileMismatch>> {
187        let lockfile_exists = self.exists();
188        if lockfile_exists {
189            self.read().wrap_err("Failed to read foundry.lock")?;
190        } else {
191            self.deps.clear();
192        }
193
194        let git = self.git.ok_or_eyre("Git is required to check foundry.lock")?;
195        let project_root =
196            canonicalize_path(self.lockfile_path.parent().expect("lockfile path has a parent"))?;
197        let git_root = match Git::root_of(git.root) {
198            Ok(root) => root,
199            Err(_)
200                if !lockfile_exists
201                    && !project_root.ancestors().any(|root| root.join(".git").exists()) =>
202            {
203                return Ok(Vec::new());
204            }
205            Err(err) => return Err(err),
206        };
207        let project_prefix = project_root.strip_prefix(&git_root).map_err(|_| {
208            eyre::eyre!("Project root is not contained in Git root {}", git_root.display())
209        })?;
210
211        let repository_path = relative_path(&git_root, &project_root);
212        let git_submodules =
213            git.submodules_in_worktree(&repository_path, &git_root, project_prefix)?;
214        let mut submodules = BTreeMap::new();
215        for submodule in &git_submodules {
216            submodules.insert(submodule.path().clone(), submodule);
217        }
218
219        let mut mismatches = Vec::new();
220        for (path, dep) in &self.deps {
221            let expected = dep.rev();
222            let Some(submodule) = submodules.remove(path) else {
223                mismatches.push(LockfileMismatch::MissingSubmodule {
224                    path: path.clone(),
225                    expected: expected.to_string(),
226                });
227                continue;
228            };
229            match submodule.status() {
230                SubmoduleCheckoutStatus::Uninitialized => {
231                    mismatches.push(LockfileMismatch::Uninitialized {
232                        path: path.clone(),
233                        expected: Some(expected.to_string()),
234                    });
235                }
236                SubmoduleCheckoutStatus::Conflicted => {
237                    mismatches.push(LockfileMismatch::Conflicted { path: path.clone() });
238                }
239                SubmoduleCheckoutStatus::MissingMapping => {
240                    mismatches
241                        .push(LockfileMismatch::MissingSubmoduleMapping { path: path.clone() });
242                }
243                SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified
244                    if submodule.rev() != expected =>
245                {
246                    mismatches.push(LockfileMismatch::Revision {
247                        path: path.clone(),
248                        expected: expected.to_string(),
249                        actual: submodule.rev().to_string(),
250                    });
251                }
252                SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified => {}
253            }
254        }
255        for (path, submodule) in submodules {
256            match submodule.status() {
257                SubmoduleCheckoutStatus::MissingMapping => {
258                    mismatches.push(LockfileMismatch::MissingSubmoduleMapping { path });
259                }
260                SubmoduleCheckoutStatus::Uninitialized => {
261                    mismatches.push(LockfileMismatch::MissingLockEntry {
262                        path: path.clone(),
263                        actual: None,
264                    });
265                    mismatches.push(LockfileMismatch::Uninitialized { path, expected: None });
266                }
267                SubmoduleCheckoutStatus::Conflicted => {
268                    mismatches.push(LockfileMismatch::MissingLockEntry {
269                        path: path.clone(),
270                        actual: None,
271                    });
272                    mismatches.push(LockfileMismatch::Conflicted { path });
273                }
274                SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified => {
275                    mismatches.push(LockfileMismatch::MissingLockEntry {
276                        path,
277                        actual: Some(submodule.rev().to_string()),
278                    });
279                }
280            }
281        }
282        mismatches.sort_by(|a, b| a.path().cmp(b.path()));
283        Ok(mismatches)
284    }
285
286    /// Loads the lockfile from the project root.
287    ///
288    /// Throws an error if the lockfile does not exist.
289    pub fn read(&mut self) -> Result<()> {
290        if !self.lockfile_path.exists() {
291            return Err(eyre::eyre!("Lockfile not found at {}", self.lockfile_path.display()));
292        }
293
294        let lockfile_str = foundry_common::fs::read_to_string(&self.lockfile_path)?;
295
296        self.deps = serde_json::from_str(&lockfile_str)?;
297
298        trace!(lockfile = ?self.deps, "loaded lockfile");
299
300        Ok(())
301    }
302
303    /// Writes the lockfile to the project root.
304    pub fn write(&self) -> Result<()> {
305        let ordered_deps: BTreeMap<_, _> = self.deps.clone().into_iter().collect();
306        foundry_common::fs::write_pretty_json_file(&self.lockfile_path, &ordered_deps)?;
307        trace!(at= ?self.lockfile_path, "wrote lockfile");
308
309        Ok(())
310    }
311
312    /// Insert a dependency into the lockfile.
313    /// If the dependency already exists, it will be updated.
314    ///
315    /// Note: This does not write the updated lockfile to disk, only inserts the dep in-memory.
316    pub fn insert(&mut self, path: PathBuf, dep_id: DepIdentifier) {
317        self.deps.insert(path, dep_id);
318    }
319
320    /// Get the [`DepIdentifier`] for a submodule at a given path.
321    pub fn get(&self, path: &Path) -> Option<&DepIdentifier> {
322        self.deps.get(path)
323    }
324
325    /// Removes a dependency from the lockfile.
326    ///
327    /// Note: This does not write the updated lockfile to disk, only removes the dep in-memory.
328    pub fn remove(&mut self, path: &Path) -> Option<DepIdentifier> {
329        self.deps.remove(path)
330    }
331
332    /// Override a dependency in the lockfile.
333    ///
334    /// Returns the overridden/previous [`DepIdentifier`].
335    /// This is used in `forge update` to decide whether a dep's tag/branch/rev should be updated.
336    ///
337    /// Throws an error if the dependency is not found in the lockfile.
338    pub fn override_dep(
339        &mut self,
340        dep: &Path,
341        mut new_dep_id: DepIdentifier,
342    ) -> Result<DepIdentifier> {
343        let prev = self
344            .deps
345            .get_mut(dep)
346            .map(|d| {
347                new_dep_id.mark_override();
348                std::mem::replace(d, new_dep_id)
349            })
350            .ok_or_eyre(format!("Dependency not found in lockfile: {}", dep.display()))?;
351
352        Ok(prev)
353    }
354
355    /// Returns the num of dependencies in the lockfile.
356    pub fn len(&self) -> usize {
357        self.deps.len()
358    }
359
360    /// Returns whether the lockfile is empty.
361    pub fn is_empty(&self) -> bool {
362        self.deps.is_empty()
363    }
364
365    /// Returns an iterator over the lockfile.
366    pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &DepIdentifier)> {
367        self.deps.iter()
368    }
369
370    /// Returns an mutable iterator over the lockfile.
371    pub fn iter_mut(&mut self) -> impl Iterator<Item = (&PathBuf, &mut DepIdentifier)> {
372        self.deps.iter_mut()
373    }
374
375    pub fn exists(&self) -> bool {
376        self.lockfile_path.exists()
377    }
378}
379
380fn relative_path(path: &Path, base: &Path) -> PathBuf {
381    let common =
382        path.components().zip(base.components()).take_while(|(path, base)| path == base).count();
383    let mut relative = PathBuf::new();
384    for _ in common..base.components().count() {
385        relative.push("..");
386    }
387    relative.extend(path.components().skip(common));
388    relative
389}
390
391// Implement .iter() for &LockFile
392
393/// Identifies whether a dependency (submodule) is referenced by a branch,
394/// tag or rev (commit hash).
395///
396/// Each enum variant consists of an `r#override` flag which is used in `forge update` to decide
397/// whether to update a dep or not. This flag is skipped during serialization.
398#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
399pub enum DepIdentifier {
400    /// `name` of the branch and the `rev`  it is currently pointing to.
401    /// Running `forge update`, will update the `name` branch to the latest `rev`.
402    #[serde(rename = "branch")]
403    Branch {
404        name: String,
405        rev: String,
406        #[serde(skip)]
407        r#override: bool,
408    },
409    /// Release tag `name` and the `rev` it is currently pointing to.
410    /// Running `forge update` does not update the tag/rev.
411    /// Dependency will remain pinned to the existing tag/rev unless r#override like so `forge
412    /// update owner/dep@tag=different_tag`.
413    #[serde(rename = "tag")]
414    Tag {
415        name: String,
416        rev: String,
417        #[serde(skip)]
418        r#override: bool,
419    },
420    /// Commit hash `rev` the submodule is currently pointing to.
421    /// Running `forge update` does not update the rev.
422    /// Dependency will remain pinned to the existing rev unless r#override.
423    #[serde(rename = "rev", untagged)]
424    Rev {
425        rev: String,
426        #[serde(skip)]
427        r#override: bool,
428    },
429}
430
431impl DepIdentifier {
432    /// Resolves the [`DepIdentifier`] for a submodule at a given path.
433    /// `lib_path` is the absolute path to the submodule.
434    pub fn resolve_type(git: &Git<'_>, lib_path: &Path, s: &str) -> Result<Self> {
435        trace!(lib_path = ?lib_path, resolving_type = ?s, "resolving submodule identifier");
436        // Get the tags for the submodule
437        if git.has_tag(s, lib_path)? {
438            let rev = git.get_rev(s, lib_path)?;
439            return Ok(Self::Tag { name: String::from(s), rev, r#override: false });
440        }
441
442        if git.has_branch(s, lib_path)? {
443            let rev = git.get_rev(s, lib_path)?;
444            return Ok(Self::Branch { name: String::from(s), rev, r#override: false });
445        }
446
447        if git.has_rev(s, lib_path)? {
448            return Ok(Self::Rev { rev: String::from(s), r#override: false });
449        }
450
451        Err(eyre::eyre!("Could not resolve tag type for submodule at path {}", lib_path.display()))
452    }
453
454    /// Get the commit hash of the dependency.
455    pub fn rev(&self) -> &str {
456        match self {
457            Self::Branch { rev, .. } => rev,
458            Self::Tag { rev, .. } => rev,
459            Self::Rev { rev, .. } => rev,
460        }
461    }
462
463    /// Get the name of the dependency.
464    ///
465    /// In case of a Rev, this will return the commit hash.
466    pub fn name(&self) -> &str {
467        match self {
468            Self::Branch { name, .. } => name,
469            Self::Tag { name, .. } => name,
470            Self::Rev { rev, .. } => rev,
471        }
472    }
473
474    /// Get the name/rev to checkout at.
475    pub fn checkout_id(&self) -> &str {
476        match self {
477            Self::Branch { name, .. } => name,
478            Self::Tag { name, .. } => name,
479            Self::Rev { rev, .. } => rev,
480        }
481    }
482
483    /// Marks as dependency as overridden.
484    pub const fn mark_override(&mut self) {
485        match self {
486            Self::Branch { r#override, .. } => *r#override = true,
487            Self::Tag { r#override, .. } => *r#override = true,
488            Self::Rev { r#override, .. } => *r#override = true,
489        }
490    }
491
492    /// Returns whether the dependency has been overridden.
493    pub const fn overridden(&self) -> bool {
494        match self {
495            Self::Branch { r#override, .. } => *r#override,
496            Self::Tag { r#override, .. } => *r#override,
497            Self::Rev { r#override, .. } => *r#override,
498        }
499    }
500
501    /// Returns whether the dependency is a branch.
502    pub const fn is_branch(&self) -> bool {
503        matches!(self, Self::Branch { .. })
504    }
505}
506
507impl std::fmt::Display for DepIdentifier {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509        match self {
510            Self::Branch { name, rev, .. } => write!(f, "branch={name}@{rev}"),
511            Self::Tag { name, rev, .. } => write!(f, "tag={name}@{rev}"),
512            Self::Rev { rev, .. } => write!(f, "rev={rev}"),
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use std::fs;
521    use tempfile::tempdir;
522
523    #[test]
524    fn serde_dep_identifier() {
525        let branch = DepIdentifier::Branch {
526            name: "main".to_string(),
527            rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
528            r#override: false,
529        };
530
531        let tag = DepIdentifier::Tag {
532            name: "v0.1.0".to_string(),
533            rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
534            r#override: false,
535        };
536
537        let rev = DepIdentifier::Rev {
538            rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(),
539            r#override: false,
540        };
541
542        let branch_str = serde_json::to_string(&branch).unwrap();
543        let tag_str = serde_json::to_string(&tag).unwrap();
544        let rev_str = serde_json::to_string(&rev).unwrap();
545
546        assert_eq!(
547            branch_str,
548            r#"{"branch":{"name":"main","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"#
549        );
550        assert_eq!(
551            tag_str,
552            r#"{"tag":{"name":"v0.1.0","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"#
553        );
554        assert_eq!(rev_str, r#"{"rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}"#);
555
556        let branch_de: DepIdentifier = serde_json::from_str(&branch_str).unwrap();
557        let tag_de: DepIdentifier = serde_json::from_str(&tag_str).unwrap();
558        let rev_de: DepIdentifier = serde_json::from_str(&rev_str).unwrap();
559
560        assert_eq!(branch, branch_de);
561        assert_eq!(tag, tag_de);
562        assert_eq!(rev, rev_de);
563    }
564
565    #[test]
566    fn test_write_ordered_deps() {
567        let dir = tempdir().unwrap();
568        let mut lockfile = Lockfile::new(dir.path());
569        lockfile.insert(
570            PathBuf::from("z_dep"),
571            DepIdentifier::Rev { rev: "3".to_string(), r#override: false },
572        );
573        lockfile.insert(
574            PathBuf::from("a_dep"),
575            DepIdentifier::Rev { rev: "1".to_string(), r#override: false },
576        );
577        lockfile.insert(
578            PathBuf::from("c_dep"),
579            DepIdentifier::Rev { rev: "2".to_string(), r#override: false },
580        );
581        let _ = lockfile.write();
582        let contents = fs::read_to_string(lockfile.lockfile_path).unwrap();
583        let expected = r#"{
584  "a_dep": {
585    "rev": "1"
586  },
587  "c_dep": {
588    "rev": "2"
589  },
590  "z_dep": {
591    "rev": "3"
592  }
593}"#;
594        assert_eq!(contents.trim(), expected.trim());
595
596        let mut lockfile = Lockfile::new(dir.path());
597        lockfile.read().unwrap();
598        lockfile.insert(
599            PathBuf::from("x_dep"),
600            DepIdentifier::Rev { rev: "4".to_string(), r#override: false },
601        );
602        let _ = lockfile.write();
603        let contents = fs::read_to_string(lockfile.lockfile_path).unwrap();
604        let expected = r#"{
605  "a_dep": {
606    "rev": "1"
607  },
608  "c_dep": {
609    "rev": "2"
610  },
611  "x_dep": {
612    "rev": "4"
613  },
614  "z_dep": {
615    "rev": "3"
616  }
617}"#;
618        assert_eq!(contents.trim(), expected.trim());
619    }
620}