Skip to main content

forc_pkg/source/git/
mod.rs

1mod auth;
2
3use crate::manifest::GenericManifestFile;
4use crate::{
5    manifest::{self, PackageManifestFile},
6    source,
7};
8use anyhow::{anyhow, bail, Context, Result};
9use forc_tracing::println_action_green;
10use forc_util::git_checkouts_directory;
11use serde::{Deserialize, Serialize};
12use std::fmt::Display;
13use std::{
14    collections::hash_map,
15    fmt, fs,
16    path::{Path, PathBuf},
17    str::FromStr,
18};
19
20#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
21pub struct Url {
22    url: gix_url::Url,
23}
24
25/// A git repo with a `Forc.toml` manifest at its root.
26#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
27pub struct Source {
28    /// The URL at which the repository is located.
29    pub repo: Url,
30    /// A git reference, e.g. a branch or tag.
31    pub reference: Reference,
32}
33
34impl Display for Source {
35    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
36        write!(f, "{} {}", self.repo, self.reference)
37    }
38}
39
40/// Used to distinguish between types of git references.
41///
42/// For the most part, `Reference` is useful to refine the `refspecs` used to fetch remote
43/// repositories.
44#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize, Default)]
45pub enum Reference {
46    Branch(String),
47    Tag(String),
48    Rev(String),
49    #[default]
50    DefaultBranch,
51}
52
53/// A pinned instance of a git source.
54#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
55pub struct Pinned {
56    /// The git source that is being pinned.
57    pub source: Source,
58    /// The hash to which we have pinned the source.
59    pub commit_hash: String,
60}
61
62/// Error returned upon failed parsing of `Pinned::from_str`.
63#[derive(Clone, Debug)]
64pub enum PinnedParseError {
65    Prefix,
66    Url,
67    Reference,
68    CommitHash,
69}
70
71/// Represents the Head's commit hash and time (in seconds) from epoch
72type HeadWithTime = (String, i64);
73
74const DEFAULT_REMOTE_NAME: &str = "origin";
75
76/// Everything needed to recognize a checkout in offline mode
77///
78/// Since we are omitting `.git` folder to save disk space, we need an indexing file
79/// to recognize a checkout while searching local checkouts in offline mode
80#[derive(Serialize, Deserialize)]
81pub struct SourceIndex {
82    /// Type of the git reference
83    pub git_reference: Reference,
84    pub head_with_time: HeadWithTime,
85}
86
87impl SourceIndex {
88    pub fn new(time: i64, git_reference: Reference, commit_hash: String) -> SourceIndex {
89        SourceIndex {
90            git_reference,
91            head_with_time: (commit_hash, time),
92        }
93    }
94}
95
96impl Reference {
97    /// Resolves the parsed forc git reference to the associated git ID.
98    pub fn resolve(&self, repo: &git2::Repository) -> Result<git2::Oid> {
99        // Find the commit associated with this tag.
100        fn resolve_tag(repo: &git2::Repository, tag: &str) -> Result<git2::Oid> {
101            let refname = format!("refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{tag}");
102            let id = repo.refname_to_id(&refname)?;
103            let obj = repo.find_object(id, None)?;
104            let obj = obj.peel(git2::ObjectType::Commit)?;
105            Ok(obj.id())
106        }
107
108        // Resolve to the target for the given branch.
109        fn resolve_branch(repo: &git2::Repository, branch: &str) -> Result<git2::Oid> {
110            let name = format!("{DEFAULT_REMOTE_NAME}/{branch}");
111            let b = repo
112                .find_branch(&name, git2::BranchType::Remote)
113                .with_context(|| format!("failed to find branch `{branch}`"))?;
114            b.get()
115                .target()
116                .ok_or_else(|| anyhow::format_err!("branch `{}` did not have a target", branch))
117        }
118
119        // Use the HEAD commit when default branch is specified.
120        fn resolve_default_branch(repo: &git2::Repository) -> Result<git2::Oid> {
121            let head_id =
122                repo.refname_to_id(&format!("refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"))?;
123            let head = repo.find_object(head_id, None)?;
124            Ok(head.peel(git2::ObjectType::Commit)?.id())
125        }
126
127        // Find the commit for the given revision.
128        fn resolve_rev(repo: &git2::Repository, rev: &str) -> Result<git2::Oid> {
129            let obj = repo.revparse_single(rev)?;
130            match obj.as_tag() {
131                Some(tag) => Ok(tag.target_id()),
132                None => Ok(obj.id()),
133            }
134        }
135
136        match self {
137            Reference::Tag(s) => {
138                resolve_tag(repo, s).with_context(|| format!("failed to find tag `{s}`"))
139            }
140            Reference::Branch(s) => resolve_branch(repo, s),
141            Reference::DefaultBranch => resolve_default_branch(repo),
142            Reference::Rev(s) => resolve_rev(repo, s),
143        }
144    }
145}
146
147impl Pinned {
148    pub const PREFIX: &'static str = "git";
149}
150
151impl source::Pin for Source {
152    type Pinned = Pinned;
153    fn pin(&self, ctx: source::PinCtx) -> Result<(Self::Pinned, PathBuf)> {
154        // If the git source directly specifies a full commit hash, we should check
155        // to see if we have a local copy. Otherwise we cannot know what commit we should pin
156        // to without fetching the repo into a temporary directory.
157        let pinned = if ctx.offline() {
158            let (_local_path, commit_hash) =
159                search_source_locally(ctx.name(), self)?.ok_or_else(|| {
160                    anyhow!(
161                        "Unable to fetch pkg {:?} from  {:?} in offline mode",
162                        ctx.name(),
163                        self.repo
164                    )
165                })?;
166            Pinned {
167                source: self.clone(),
168                commit_hash,
169            }
170        } else if let Reference::DefaultBranch | Reference::Branch(_) = self.reference {
171            // If the reference is to a branch or to the default branch we need to fetch
172            // from remote even though we may have it locally. Because remote may contain a
173            // newer commit.
174            pin(ctx.fetch_id(), ctx.name(), self.clone())?
175        } else {
176            // If we are in online mode and the reference is to a specific commit (tag or
177            // rev) we can first search it locally and re-use it.
178            match search_source_locally(ctx.name(), self) {
179                Ok(Some((_local_path, commit_hash))) => Pinned {
180                    source: self.clone(),
181                    commit_hash,
182                },
183                _ => {
184                    // If the checkout we are looking for does not exists locally or an
185                    // error happened during the search fetch it
186                    pin(ctx.fetch_id(), ctx.name(), self.clone())?
187                }
188            }
189        };
190        let repo_path = commit_path(ctx.name(), &pinned.source.repo, &pinned.commit_hash);
191        Ok((pinned, repo_path))
192    }
193}
194
195impl source::Fetch for Pinned {
196    fn fetch(&self, ctx: source::PinCtx, repo_path: &Path) -> Result<PackageManifestFile> {
197        // Co-ordinate access to the git checkout directory using an advisory file lock.
198        let mut lock = forc_util::path_lock(repo_path)?;
199        // TODO: Here we assume that if the local path already exists, that it contains the
200        // full and correct source for that commit and hasn't been tampered with. This is
201        // probably fine for most cases as users should never be touching these
202        // directories, however we should add some code to validate this. E.g. can we
203        // recreate the git hash by hashing the directory or something along these lines
204        // using git?
205        // https://github.com/FuelLabs/sway/issues/7075
206        {
207            let _guard = lock.write()?;
208            if !repo_path.exists() {
209                println_action_green(
210                    "Fetching",
211                    &format!("{} {}", ansiterm::Style::new().bold().paint(ctx.name), self),
212                );
213                fetch(ctx.fetch_id(), ctx.name(), self)?;
214            }
215        }
216        let path = {
217            let _guard = lock.read()?;
218            manifest::find_within(repo_path, ctx.name())
219                .ok_or_else(|| anyhow!("failed to find package `{}` in {}", ctx.name(), self))?
220        };
221        PackageManifestFile::from_file(path)
222    }
223}
224
225impl source::DepPath for Pinned {
226    fn dep_path(&self, name: &str) -> anyhow::Result<source::DependencyPath> {
227        let repo_path = commit_path(name, &self.source.repo, &self.commit_hash);
228        // Co-ordinate access to the git checkout directory using an advisory file lock.
229        let lock = forc_util::path_lock(&repo_path)?;
230        let _guard = lock.read()?;
231        let path = manifest::find_within(&repo_path, name)
232            .ok_or_else(|| anyhow!("failed to find package `{}` in {}", name, self))?;
233        Ok(source::DependencyPath::ManifestPath(path))
234    }
235}
236
237impl fmt::Display for Url {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        let url_string = self.url.to_bstring().to_string();
240        write!(f, "{url_string}")
241    }
242}
243
244impl fmt::Display for Pinned {
245    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
246        // git+<url/to/repo>?<ref_kind>=<ref_string>#<commit>
247        write!(
248            f,
249            "{}+{}?{}#{}",
250            Self::PREFIX,
251            self.source.repo,
252            self.source.reference,
253            self.commit_hash
254        )
255    }
256}
257
258impl fmt::Display for Reference {
259    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
260        match self {
261            Reference::Branch(ref s) => write!(f, "branch={s}"),
262            Reference::Tag(ref s) => write!(f, "tag={s}"),
263            Reference::Rev(ref _s) => write!(f, "rev"),
264            Reference::DefaultBranch => write!(f, "default-branch"),
265        }
266    }
267}
268
269impl FromStr for Url {
270    type Err = anyhow::Error;
271
272    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
273        let url = gix_url::Url::from_bytes(s.as_bytes().into()).map_err(|e| anyhow!("{}", e))?;
274        Ok(Self { url })
275    }
276}
277
278impl FromStr for Pinned {
279    type Err = PinnedParseError;
280    fn from_str(s: &str) -> Result<Self, Self::Err> {
281        // git+<url/to/repo>?<reference>#<commit>
282        let s = s.trim();
283
284        // Check for "git+" at the start.
285        let prefix_plus = format!("{}+", Self::PREFIX);
286        if s.find(&prefix_plus) != Some(0) {
287            return Err(PinnedParseError::Prefix);
288        }
289        let s = &s[prefix_plus.len()..];
290
291        // Parse the `repo` URL.
292        let repo_str = s.split('?').next().ok_or(PinnedParseError::Url)?;
293        let repo = Url::from_str(repo_str).map_err(|_| PinnedParseError::Url)?;
294        let s = &s[repo_str.len() + "?".len()..];
295
296        // Parse the git reference and commit hash. This can be any of either:
297        // - `branch=<branch-name>#<commit-hash>`
298        // - `tag=<tag-name>#<commit-hash>`
299        // - `rev#<commit-hash>`
300        // - `default#<commit-hash>`
301        let mut s_iter = s.split('#');
302        let reference = s_iter.next().ok_or(PinnedParseError::Reference)?;
303        let commit_hash = s_iter
304            .next()
305            .ok_or(PinnedParseError::CommitHash)?
306            .to_string();
307        validate_git_commit_hash(&commit_hash).map_err(|_| PinnedParseError::CommitHash)?;
308
309        const BRANCH: &str = "branch=";
310        const TAG: &str = "tag=";
311        let reference = if reference.find(BRANCH) == Some(0) {
312            Reference::Branch(reference[BRANCH.len()..].to_string())
313        } else if reference.find(TAG) == Some(0) {
314            Reference::Tag(reference[TAG.len()..].to_string())
315        } else if reference == "rev" {
316            Reference::Rev(commit_hash.to_string())
317        } else if reference == "default-branch" {
318            Reference::DefaultBranch
319        } else {
320            return Err(PinnedParseError::Reference);
321        };
322
323        let source = Source { repo, reference };
324        Ok(Self {
325            source,
326            commit_hash,
327        })
328    }
329}
330
331impl From<Pinned> for source::Pinned {
332    fn from(p: Pinned) -> Self {
333        Self::Git(p)
334    }
335}
336
337/// The name to use for a package's git repository under the user's forc directory.
338fn git_repo_dir_name(name: &str, repo: &Url) -> String {
339    use std::hash::{Hash, Hasher};
340    fn hash_url(url: &Url) -> u64 {
341        let mut hasher = hash_map::DefaultHasher::new();
342        url.hash(&mut hasher);
343        hasher.finish()
344    }
345    let repo_url_hash = hash_url(repo);
346    format!("{name}-{repo_url_hash:x}")
347}
348
349fn validate_git_commit_hash(commit_hash: &str) -> Result<()> {
350    const LEN: usize = 40;
351    if commit_hash.len() != LEN {
352        bail!(
353            "invalid hash length: expected {}, found {}",
354            LEN,
355            commit_hash.len()
356        );
357    }
358    if !commit_hash.chars().all(|c| c.is_ascii_alphanumeric()) {
359        bail!("hash contains one or more non-ascii-alphanumeric characters");
360    }
361    Ok(())
362}
363
364/// A temporary directory that we can use for cloning a git-sourced package's repo and discovering
365/// the current HEAD for the given git reference.
366///
367/// The resulting directory is:
368///
369/// ```ignore
370/// $HOME/.forc/git/checkouts/tmp/<fetch_id>-name-<repo_url_hash>
371/// ```
372///
373/// A unique `fetch_id` may be specified to avoid contention over the git repo directory in the
374/// case that multiple processes or threads may be building different projects that may require
375/// fetching the same dependency.
376fn tmp_git_repo_dir(fetch_id: u64, name: &str, repo: &Url) -> PathBuf {
377    let repo_dir_name = format!("{:x}-{}", fetch_id, git_repo_dir_name(name, repo));
378    git_checkouts_directory().join("tmp").join(repo_dir_name)
379}
380
381/// Given a git reference, build a list of `refspecs` required for the fetch operation.
382///
383/// Also returns whether or not our reference implies we require fetching tags.
384fn git_ref_to_refspecs(reference: &Reference) -> (Vec<String>, bool) {
385    let mut refspecs = vec![];
386    let mut tags = false;
387    match reference {
388        Reference::Branch(s) => {
389            refspecs.push(format!(
390                "+refs/heads/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/{s}"
391            ));
392        }
393        Reference::Tag(s) => {
394            refspecs.push(format!(
395                "+refs/tags/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{s}"
396            ));
397        }
398        Reference::Rev(s) => {
399            if s.starts_with("refs/") {
400                refspecs.push(format!("+{s}:{s}"));
401            } else {
402                // We can't fetch the commit directly, so we fetch all branches and tags in order
403                // to find it.
404                refspecs.push(format!(
405                    "+refs/heads/*:refs/remotes/{DEFAULT_REMOTE_NAME}/*"
406                ));
407                refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
408                tags = true;
409            }
410        }
411        Reference::DefaultBranch => {
412            refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
413        }
414    }
415    (refspecs, tags)
416}
417
418/// Initializes a temporary git repo for the package and fetches only the reference associated with
419/// the given source.
420fn with_tmp_git_repo<F, O>(fetch_id: u64, name: &str, source: &Source, f: F) -> Result<O>
421where
422    F: FnOnce(git2::Repository) -> Result<O>,
423{
424    // Clear existing temporary directory if it exists.
425    let repo_dir = tmp_git_repo_dir(fetch_id, name, &source.repo);
426    if repo_dir.exists() {
427        let _ = std::fs::remove_dir_all(&repo_dir);
428    }
429
430    // Add a guard to ensure cleanup happens if we got out of scope whether by
431    // returning or panicking.
432    let _cleanup_guard = scopeguard::guard(&repo_dir, |dir| {
433        let _ = std::fs::remove_dir_all(dir);
434    });
435
436    let config = git2::Config::open_default().unwrap();
437
438    // Init auth manager
439    let mut auth_handler = auth::AuthHandler::default_with_config(config);
440
441    // Setup remote callbacks
442    let mut callback = git2::RemoteCallbacks::new();
443    callback.credentials(move |url, username, allowed| {
444        auth_handler.handle_callback(url, username, allowed)
445    });
446
447    // Initialise the repository.
448    let repo = git2::Repository::init(&repo_dir)
449        .map_err(|e| anyhow!("failed to init repo at \"{}\": {}", repo_dir.display(), e))?;
450
451    // Fetch the necessary references.
452    let (refspecs, tags) = git_ref_to_refspecs(&source.reference);
453
454    // Fetch the refspecs.
455    let mut fetch_opts = git2::FetchOptions::new();
456    fetch_opts.remote_callbacks(callback);
457
458    if tags {
459        fetch_opts.download_tags(git2::AutotagOption::All);
460    }
461    let repo_url_string = source.repo.to_string();
462    repo.remote_anonymous(&repo_url_string)?
463        .fetch(&refspecs, Some(&mut fetch_opts), None)
464        .with_context(|| {
465            format!(
466                "failed to fetch `{}`. Check your connection or run in `--offline` mode",
467                &repo_url_string
468            )
469        })?;
470
471    // Call the user function.
472    let output = f(repo)?;
473    Ok(output)
474}
475
476/// Pin the given git-sourced package.
477///
478/// This clones the repository to a temporary directory in order to determine the commit at the
479/// HEAD of the given git reference.
480pub fn pin(fetch_id: u64, name: &str, source: Source) -> Result<Pinned> {
481    let commit_hash = with_tmp_git_repo(fetch_id, name, &source, |repo| {
482        // Resolve the reference to the commit ID.
483        let commit_id = source
484            .reference
485            .resolve(&repo)
486            .with_context(|| format!("Failed to resolve manifest reference: {source}"))?;
487        Ok(format!("{commit_id}"))
488    })?;
489    Ok(Pinned {
490        source,
491        commit_hash,
492    })
493}
494
495/// The path to which a git package commit should be checked out.
496///
497/// The resulting directory is:
498///
499/// ```ignore
500/// $HOME/.forc/git/checkouts/name-<repo_url_hash>/<commit_hash>
501/// ```
502///
503/// where `<repo_url_hash>` is a hash of the source repository URL.
504pub fn commit_path(name: &str, repo: &Url, commit_hash: &str) -> PathBuf {
505    let repo_dir_name = git_repo_dir_name(name, repo);
506    git_checkouts_directory()
507        .join(repo_dir_name)
508        .join(commit_hash)
509}
510
511/// Fetch the repo at the given git package's URL and checkout the pinned commit.
512///
513/// Returns the location of the checked out commit.
514///
515/// NOTE: This function assumes that the caller has acquired an advisory lock to co-ordinate access
516/// to the git repository checkout path.
517pub fn fetch(fetch_id: u64, name: &str, pinned: &Pinned) -> Result<PathBuf> {
518    let path = commit_path(name, &pinned.source.repo, &pinned.commit_hash);
519    // Checkout the pinned hash to the path.
520    with_tmp_git_repo(fetch_id, name, &pinned.source, |repo| {
521        // Change HEAD to point to the pinned commit.
522        let id = git2::Oid::from_str(&pinned.commit_hash)?;
523        repo.set_head_detached(id)?;
524
525        // If the directory exists, remove it. Note that we already check for an existing,
526        // cached checkout directory for re-use prior to reaching the `fetch` function.
527        if path.exists() {
528            let _ = fs::remove_dir_all(&path);
529        }
530        fs::create_dir_all(&path)?;
531
532        // Checkout HEAD to the target directory.
533        let mut checkout = git2::build::CheckoutBuilder::new();
534        checkout.force().target_dir(&path);
535        repo.checkout_head(Some(&mut checkout))?;
536
537        // Fetch HEAD time and create an index
538        let current_head = repo.revparse_single("HEAD")?;
539        let head_commit = current_head
540            .as_commit()
541            .ok_or_else(|| anyhow!("Cannot get commit from {}", current_head.id()))?;
542        let head_time = head_commit.time().seconds();
543        let source_index = SourceIndex::new(
544            head_time,
545            pinned.source.reference.clone(),
546            pinned.commit_hash.clone(),
547        );
548
549        // Write the index file
550        fs::write(
551            path.join(".forc_index"),
552            serde_json::to_string(&source_index)?,
553        )?;
554        Ok(())
555    })?;
556    Ok(path)
557}
558
559/// Search local checkout dir for git sources, for non-branch git references tries to find the
560/// exact match. For branch references, tries to find the most recent repo present locally with the given repo
561pub(crate) fn search_source_locally(
562    name: &str,
563    git_source: &Source,
564) -> Result<Option<(PathBuf, String)>> {
565    // In the checkouts dir iterate over dirs whose name starts with `name`
566    let checkouts_dir = git_checkouts_directory();
567    match &git_source.reference {
568        Reference::Branch(branch) => {
569            // Collect repos from this branch with their HEAD time
570            let repos_from_branch = collect_local_repos_with_branch(checkouts_dir, name, branch)?;
571            // Get the newest repo by their HEAD commit times
572            let newest_branch_repo = repos_from_branch
573                .into_iter()
574                .max_by_key(|&(_, (_, time))| time)
575                .map(|(repo_path, (hash, _))| (repo_path, hash));
576            Ok(newest_branch_repo)
577        }
578        _ => find_exact_local_repo_with_reference(checkouts_dir, name, &git_source.reference),
579    }
580}
581
582/// Search and collect repos from checkouts_dir that are from given branch and for the given package
583fn collect_local_repos_with_branch(
584    checkouts_dir: PathBuf,
585    package_name: &str,
586    branch_name: &str,
587) -> Result<Vec<(PathBuf, HeadWithTime)>> {
588    let mut list_of_repos = Vec::new();
589    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
590        // Check if the repo's HEAD commit to verify it is from desired branch
591        if let Reference::Branch(branch) = repo_index.git_reference {
592            if branch == branch_name {
593                list_of_repos.push((repo_dir_path, repo_index.head_with_time));
594            }
595        }
596        Ok(())
597    })?;
598    Ok(list_of_repos)
599}
600
601/// Search an exact reference in locally available repos
602fn find_exact_local_repo_with_reference(
603    checkouts_dir: PathBuf,
604    package_name: &str,
605    git_reference: &Reference,
606) -> Result<Option<(PathBuf, String)>> {
607    let mut found_local_repo = None;
608    if let Reference::Tag(tag) = git_reference {
609        found_local_repo = find_repo_with_tag(tag, package_name, checkouts_dir)?;
610    } else if let Reference::Rev(rev) = git_reference {
611        found_local_repo = find_repo_with_rev(rev, package_name, checkouts_dir)?;
612    }
613    Ok(found_local_repo)
614}
615
616/// Search and find the match repo between the given tag and locally available options
617fn find_repo_with_tag(
618    tag: &str,
619    package_name: &str,
620    checkouts_dir: PathBuf,
621) -> Result<Option<(PathBuf, String)>> {
622    let mut found_local_repo = None;
623    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
624        // Get current head of the repo
625        let current_head = repo_index.head_with_time.0;
626        if let Reference::Tag(curr_repo_tag) = repo_index.git_reference {
627            if curr_repo_tag == tag {
628                found_local_repo = Some((repo_dir_path, current_head));
629            }
630        }
631        Ok(())
632    })?;
633    Ok(found_local_repo)
634}
635
636/// Search and find the match repo between the given rev and locally available options
637fn find_repo_with_rev(
638    rev: &str,
639    package_name: &str,
640    checkouts_dir: PathBuf,
641) -> Result<Option<(PathBuf, String)>> {
642    let mut found_local_repo = None;
643    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
644        // Get current head of the repo
645        let current_head = repo_index.head_with_time.0;
646        if let Reference::Rev(curr_repo_rev) = repo_index.git_reference {
647            if curr_repo_rev == rev {
648                found_local_repo = Some((repo_dir_path, current_head));
649            }
650        }
651        Ok(())
652    })?;
653    Ok(found_local_repo)
654}
655
656/// Search local checkouts directory and apply the given function. This is used for iterating over
657/// possible options of a given package.
658fn with_search_checkouts<F>(checkouts_dir: PathBuf, package_name: &str, mut f: F) -> Result<()>
659where
660    F: FnMut(SourceIndex, PathBuf) -> Result<()>,
661{
662    for entry in fs::read_dir(checkouts_dir)? {
663        let entry = entry?;
664        let folder_name = entry
665            .file_name()
666            .into_string()
667            .map_err(|_| anyhow!("invalid folder name"))?;
668        if folder_name.starts_with(package_name) {
669            // Search if the dir we are looking starts with the name of our package
670            for repo_dir in fs::read_dir(entry.path())? {
671                // Iterate over all dirs inside the `name-***` directory and try to open repo from
672                // each dirs inside this one
673                let repo_dir = repo_dir
674                    .map_err(|e| anyhow!("Cannot find local repo at checkouts dir {}", e))?;
675                if repo_dir.file_type()?.is_dir() {
676                    // Get the path of the current repo
677                    let repo_dir_path = repo_dir.path();
678                    // Get the index file from the found path
679                    if let Ok(index_file) = fs::read_to_string(repo_dir_path.join(".forc_index")) {
680                        let index = serde_json::from_str(&index_file)?;
681                        f(index, repo_dir_path)?;
682                    }
683                }
684            }
685        }
686    }
687    Ok(())
688}
689
690#[test]
691fn test_source_git_pinned_parsing() {
692    let strings = [
693        "git+https://github.com/foo/bar?branch=baz#64092602dd6158f3e41d775ed889389440a2cd86",
694        "git+https://github.com/fuellabs/sway-lib-std?tag=v0.1.0#0000000000000000000000000000000000000000",
695        "git+https://some-git-host.com/owner/repo?rev#FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
696        "git+https://some-git-host.com/owner/repo?default-branch#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
697    ];
698
699    let expected = [
700        Pinned {
701            source: Source {
702                repo: Url::from_str("https://github.com/foo/bar").unwrap(),
703                reference: Reference::Branch("baz".to_string()),
704            },
705            commit_hash: "64092602dd6158f3e41d775ed889389440a2cd86".to_string(),
706        },
707        Pinned {
708            source: Source {
709                repo: Url::from_str("https://github.com/fuellabs/sway-lib-std").unwrap(),
710                reference: Reference::Tag("v0.1.0".to_string()),
711            },
712            commit_hash: "0000000000000000000000000000000000000000".to_string(),
713        },
714        Pinned {
715            source: Source {
716                repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
717                reference: Reference::Rev("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string()),
718            },
719            commit_hash: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string(),
720        },
721        Pinned {
722            source: Source {
723                repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
724                reference: Reference::DefaultBranch,
725            },
726            commit_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
727        },
728    ];
729
730    for (&string, expected) in strings.iter().zip(&expected) {
731        let parsed = Pinned::from_str(string).unwrap();
732        assert_eq!(&parsed, expected);
733        let serialized = expected.to_string();
734        assert_eq!(&serialized, string);
735    }
736}