Skip to main content

forge/
lockfile.rs

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