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