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        {
205            let _guard = lock.write()?;
206            if !repo_path.exists() {
207                println_action_green(
208                    "Fetching",
209                    &format!("{} {}", ansiterm::Style::new().bold().paint(ctx.name), self),
210                );
211                fetch(ctx.fetch_id(), ctx.name(), self)?;
212            }
213        }
214        let path = {
215            let _guard = lock.read()?;
216            manifest::find_within(repo_path, ctx.name())
217                .ok_or_else(|| anyhow!("failed to find package `{}` in {}", ctx.name(), self))?
218        };
219        PackageManifestFile::from_file(path)
220    }
221}
222
223impl source::DepPath for Pinned {
224    fn dep_path(&self, name: &str) -> anyhow::Result<source::DependencyPath> {
225        let repo_path = commit_path(name, &self.source.repo, &self.commit_hash);
226        // Co-ordinate access to the git checkout directory using an advisory file lock.
227        let lock = forc_util::path_lock(&repo_path)?;
228        let _guard = lock.read()?;
229        let path = manifest::find_within(&repo_path, name)
230            .ok_or_else(|| anyhow!("failed to find package `{}` in {}", name, self))?;
231        Ok(source::DependencyPath::ManifestPath(path))
232    }
233}
234
235impl fmt::Display for Url {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        let url_string = self.url.to_bstring().to_string();
238        write!(f, "{url_string}")
239    }
240}
241
242impl fmt::Display for Pinned {
243    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
244        // git+<url/to/repo>?<ref_kind>=<ref_string>#<commit>
245        write!(
246            f,
247            "{}+{}?{}#{}",
248            Self::PREFIX,
249            self.source.repo,
250            self.source.reference,
251            self.commit_hash
252        )
253    }
254}
255
256impl fmt::Display for Reference {
257    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
258        match self {
259            Reference::Branch(ref s) => write!(f, "branch={s}"),
260            Reference::Tag(ref s) => write!(f, "tag={s}"),
261            Reference::Rev(ref _s) => write!(f, "rev"),
262            Reference::DefaultBranch => write!(f, "default-branch"),
263        }
264    }
265}
266
267impl FromStr for Url {
268    type Err = anyhow::Error;
269
270    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
271        let url = gix_url::Url::from_bytes(s.as_bytes().into()).map_err(|e| anyhow!("{}", e))?;
272        Ok(Self { url })
273    }
274}
275
276impl FromStr for Pinned {
277    type Err = PinnedParseError;
278    fn from_str(s: &str) -> Result<Self, Self::Err> {
279        // git+<url/to/repo>?<reference>#<commit>
280        let s = s.trim();
281
282        // Check for "git+" at the start.
283        let prefix_plus = format!("{}+", Self::PREFIX);
284        if s.find(&prefix_plus) != Some(0) {
285            return Err(PinnedParseError::Prefix);
286        }
287        let s = &s[prefix_plus.len()..];
288
289        // Parse the `repo` URL.
290        let repo_str = s.split('?').next().ok_or(PinnedParseError::Url)?;
291        let repo = Url::from_str(repo_str).map_err(|_| PinnedParseError::Url)?;
292        let s = &s[repo_str.len() + "?".len()..];
293
294        // Parse the git reference and commit hash. This can be any of either:
295        // - `branch=<branch-name>#<commit-hash>`
296        // - `tag=<tag-name>#<commit-hash>`
297        // - `rev#<commit-hash>`
298        // - `default#<commit-hash>`
299        let mut s_iter = s.split('#');
300        let reference = s_iter.next().ok_or(PinnedParseError::Reference)?;
301        let commit_hash = s_iter
302            .next()
303            .ok_or(PinnedParseError::CommitHash)?
304            .to_string();
305        validate_git_commit_hash(&commit_hash).map_err(|_| PinnedParseError::CommitHash)?;
306
307        const BRANCH: &str = "branch=";
308        const TAG: &str = "tag=";
309        let reference = if reference.find(BRANCH) == Some(0) {
310            Reference::Branch(reference[BRANCH.len()..].to_string())
311        } else if reference.find(TAG) == Some(0) {
312            Reference::Tag(reference[TAG.len()..].to_string())
313        } else if reference == "rev" {
314            Reference::Rev(commit_hash.to_string())
315        } else if reference == "default-branch" {
316            Reference::DefaultBranch
317        } else {
318            return Err(PinnedParseError::Reference);
319        };
320
321        let source = Source { repo, reference };
322        Ok(Self {
323            source,
324            commit_hash,
325        })
326    }
327}
328
329impl Default for Reference {
330    fn default() -> Self {
331        Self::DefaultBranch
332    }
333}
334
335impl From<Pinned> for source::Pinned {
336    fn from(p: Pinned) -> Self {
337        Self::Git(p)
338    }
339}
340
341/// The name to use for a package's git repository under the user's forc directory.
342fn git_repo_dir_name(name: &str, repo: &Url) -> String {
343    use std::hash::{Hash, Hasher};
344    fn hash_url(url: &Url) -> u64 {
345        let mut hasher = hash_map::DefaultHasher::new();
346        url.hash(&mut hasher);
347        hasher.finish()
348    }
349    let repo_url_hash = hash_url(repo);
350    format!("{name}-{repo_url_hash:x}")
351}
352
353fn validate_git_commit_hash(commit_hash: &str) -> Result<()> {
354    const LEN: usize = 40;
355    if commit_hash.len() != LEN {
356        bail!(
357            "invalid hash length: expected {}, found {}",
358            LEN,
359            commit_hash.len()
360        );
361    }
362    if !commit_hash.chars().all(|c| c.is_ascii_alphanumeric()) {
363        bail!("hash contains one or more non-ascii-alphanumeric characters");
364    }
365    Ok(())
366}
367
368/// A temporary directory that we can use for cloning a git-sourced package's repo and discovering
369/// the current HEAD for the given git reference.
370///
371/// The resulting directory is:
372///
373/// ```ignore
374/// $HOME/.forc/git/checkouts/tmp/<fetch_id>-name-<repo_url_hash>
375/// ```
376///
377/// A unique `fetch_id` may be specified to avoid contention over the git repo directory in the
378/// case that multiple processes or threads may be building different projects that may require
379/// fetching the same dependency.
380fn tmp_git_repo_dir(fetch_id: u64, name: &str, repo: &Url) -> PathBuf {
381    let repo_dir_name = format!("{:x}-{}", fetch_id, git_repo_dir_name(name, repo));
382    git_checkouts_directory().join("tmp").join(repo_dir_name)
383}
384
385/// Given a git reference, build a list of `refspecs` required for the fetch operation.
386///
387/// Also returns whether or not our reference implies we require fetching tags.
388fn git_ref_to_refspecs(reference: &Reference) -> (Vec<String>, bool) {
389    let mut refspecs = vec![];
390    let mut tags = false;
391    match reference {
392        Reference::Branch(s) => {
393            refspecs.push(format!(
394                "+refs/heads/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/{s}"
395            ));
396        }
397        Reference::Tag(s) => {
398            refspecs.push(format!(
399                "+refs/tags/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{s}"
400            ));
401        }
402        Reference::Rev(s) => {
403            if s.starts_with("refs/") {
404                refspecs.push(format!("+{s}:{s}"));
405            } else {
406                // We can't fetch the commit directly, so we fetch all branches and tags in order
407                // to find it.
408                refspecs.push(format!(
409                    "+refs/heads/*:refs/remotes/{DEFAULT_REMOTE_NAME}/*"
410                ));
411                refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
412                tags = true;
413            }
414        }
415        Reference::DefaultBranch => {
416            refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
417        }
418    }
419    (refspecs, tags)
420}
421
422/// Initializes a temporary git repo for the package and fetches only the reference associated with
423/// the given source.
424fn with_tmp_git_repo<F, O>(fetch_id: u64, name: &str, source: &Source, f: F) -> Result<O>
425where
426    F: FnOnce(git2::Repository) -> Result<O>,
427{
428    // Clear existing temporary directory if it exists.
429    let repo_dir = tmp_git_repo_dir(fetch_id, name, &source.repo);
430    if repo_dir.exists() {
431        let _ = std::fs::remove_dir_all(&repo_dir);
432    }
433
434    let config = git2::Config::open_default().unwrap();
435
436    // Init auth manager
437    let mut auth_handler = auth::AuthHandler::default_with_config(config);
438
439    // Setup remote callbacks
440    let mut callback = git2::RemoteCallbacks::new();
441    callback.credentials(move |url, username, allowed| {
442        auth_handler.handle_callback(url, username, allowed)
443    });
444
445    // Initialise the repository.
446    let repo = git2::Repository::init(&repo_dir)
447        .map_err(|e| anyhow!("failed to init repo at \"{}\": {}", repo_dir.display(), e))?;
448
449    // Fetch the necessary references.
450    let (refspecs, tags) = git_ref_to_refspecs(&source.reference);
451
452    // Fetch the refspecs.
453    let mut fetch_opts = git2::FetchOptions::new();
454    fetch_opts.remote_callbacks(callback);
455
456    if tags {
457        fetch_opts.download_tags(git2::AutotagOption::All);
458    }
459    let repo_url_string = source.repo.to_string();
460    repo.remote_anonymous(&repo_url_string)?
461        .fetch(&refspecs, Some(&mut fetch_opts), None)
462        .with_context(|| {
463            format!(
464                "failed to fetch `{}`. Check your connection or run in `--offline` mode",
465                &repo_url_string
466            )
467        })?;
468
469    // Call the user function.
470    let output = f(repo)?;
471
472    // Clean up the temporary directory.
473    let _ = std::fs::remove_dir_all(&repo_dir);
474    Ok(output)
475}
476
477/// Pin the given git-sourced package.
478///
479/// This clones the repository to a temporary directory in order to determine the commit at the
480/// HEAD of the given git reference.
481pub fn pin(fetch_id: u64, name: &str, source: Source) -> Result<Pinned> {
482    let commit_hash = with_tmp_git_repo(fetch_id, name, &source, |repo| {
483        // Resolve the reference to the commit ID.
484        let commit_id = source
485            .reference
486            .resolve(&repo)
487            .with_context(|| format!("Failed to resolve manifest reference: {source}"))?;
488        Ok(format!("{commit_id}"))
489    })?;
490    Ok(Pinned {
491        source,
492        commit_hash,
493    })
494}
495
496/// The path to which a git package commit should be checked out.
497///
498/// The resulting directory is:
499///
500/// ```ignore
501/// $HOME/.forc/git/checkouts/name-<repo_url_hash>/<commit_hash>
502/// ```
503///
504/// where `<repo_url_hash>` is a hash of the source repository URL.
505pub fn commit_path(name: &str, repo: &Url, commit_hash: &str) -> PathBuf {
506    let repo_dir_name = git_repo_dir_name(name, repo);
507    git_checkouts_directory()
508        .join(repo_dir_name)
509        .join(commit_hash)
510}
511
512/// Fetch the repo at the given git package's URL and checkout the pinned commit.
513///
514/// Returns the location of the checked out commit.
515///
516/// NOTE: This function assumes that the caller has acquired an advisory lock to co-ordinate access
517/// to the git repository checkout path.
518pub fn fetch(fetch_id: u64, name: &str, pinned: &Pinned) -> Result<PathBuf> {
519    let path = commit_path(name, &pinned.source.repo, &pinned.commit_hash);
520    // Checkout the pinned hash to the path.
521    with_tmp_git_repo(fetch_id, name, &pinned.source, |repo| {
522        // Change HEAD to point to the pinned commit.
523        let id = git2::Oid::from_str(&pinned.commit_hash)?;
524        repo.set_head_detached(id)?;
525
526        // If the directory exists, remove it. Note that we already check for an existing,
527        // cached checkout directory for re-use prior to reaching the `fetch` function.
528        if path.exists() {
529            let _ = fs::remove_dir_all(&path);
530        }
531        fs::create_dir_all(&path)?;
532
533        // Checkout HEAD to the target directory.
534        let mut checkout = git2::build::CheckoutBuilder::new();
535        checkout.force().target_dir(&path);
536        repo.checkout_head(Some(&mut checkout))?;
537
538        // Fetch HEAD time and create an index
539        let current_head = repo.revparse_single("HEAD")?;
540        let head_commit = current_head
541            .as_commit()
542            .ok_or_else(|| anyhow!("Cannot get commit from {}", current_head.id().to_string()))?;
543        let head_time = head_commit.time().seconds();
544        let source_index = SourceIndex::new(
545            head_time,
546            pinned.source.reference.clone(),
547            pinned.commit_hash.clone(),
548        );
549
550        // Write the index file
551        fs::write(
552            path.join(".forc_index"),
553            serde_json::to_string(&source_index)?,
554        )?;
555        Ok(())
556    })?;
557    Ok(path)
558}
559
560/// Search local checkout dir for git sources, for non-branch git references tries to find the
561/// exact match. For branch references, tries to find the most recent repo present locally with the given repo
562pub(crate) fn search_source_locally(
563    name: &str,
564    git_source: &Source,
565) -> Result<Option<(PathBuf, String)>> {
566    // In the checkouts dir iterate over dirs whose name starts with `name`
567    let checkouts_dir = git_checkouts_directory();
568    match &git_source.reference {
569        Reference::Branch(branch) => {
570            // Collect repos from this branch with their HEAD time
571            let repos_from_branch = collect_local_repos_with_branch(checkouts_dir, name, branch)?;
572            // Get the newest repo by their HEAD commit times
573            let newest_branch_repo = repos_from_branch
574                .into_iter()
575                .max_by_key(|&(_, (_, time))| time)
576                .map(|(repo_path, (hash, _))| (repo_path, hash));
577            Ok(newest_branch_repo)
578        }
579        _ => find_exact_local_repo_with_reference(checkouts_dir, name, &git_source.reference),
580    }
581}
582
583/// Search and collect repos from checkouts_dir that are from given branch and for the given package
584fn collect_local_repos_with_branch(
585    checkouts_dir: PathBuf,
586    package_name: &str,
587    branch_name: &str,
588) -> Result<Vec<(PathBuf, HeadWithTime)>> {
589    let mut list_of_repos = Vec::new();
590    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
591        // Check if the repo's HEAD commit to verify it is from desired branch
592        if let Reference::Branch(branch) = repo_index.git_reference {
593            if branch == branch_name {
594                list_of_repos.push((repo_dir_path, repo_index.head_with_time));
595            }
596        }
597        Ok(())
598    })?;
599    Ok(list_of_repos)
600}
601
602/// Search an exact reference in locally available repos
603fn find_exact_local_repo_with_reference(
604    checkouts_dir: PathBuf,
605    package_name: &str,
606    git_reference: &Reference,
607) -> Result<Option<(PathBuf, String)>> {
608    let mut found_local_repo = None;
609    if let Reference::Tag(tag) = git_reference {
610        found_local_repo = find_repo_with_tag(tag, package_name, checkouts_dir)?;
611    } else if let Reference::Rev(rev) = git_reference {
612        found_local_repo = find_repo_with_rev(rev, package_name, checkouts_dir)?;
613    }
614    Ok(found_local_repo)
615}
616
617/// Search and find the match repo between the given tag and locally available options
618fn find_repo_with_tag(
619    tag: &str,
620    package_name: &str,
621    checkouts_dir: PathBuf,
622) -> Result<Option<(PathBuf, String)>> {
623    let mut found_local_repo = None;
624    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
625        // Get current head of the repo
626        let current_head = repo_index.head_with_time.0;
627        if let Reference::Tag(curr_repo_tag) = repo_index.git_reference {
628            if curr_repo_tag == tag {
629                found_local_repo = Some((repo_dir_path, current_head));
630            }
631        }
632        Ok(())
633    })?;
634    Ok(found_local_repo)
635}
636
637/// Search and find the match repo between the given rev and locally available options
638fn find_repo_with_rev(
639    rev: &str,
640    package_name: &str,
641    checkouts_dir: PathBuf,
642) -> Result<Option<(PathBuf, String)>> {
643    let mut found_local_repo = None;
644    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
645        // Get current head of the repo
646        let current_head = repo_index.head_with_time.0;
647        if let Reference::Rev(curr_repo_rev) = repo_index.git_reference {
648            if curr_repo_rev == rev {
649                found_local_repo = Some((repo_dir_path, current_head));
650            }
651        }
652        Ok(())
653    })?;
654    Ok(found_local_repo)
655}
656
657/// Search local checkouts directory and apply the given function. This is used for iterating over
658/// possible options of a given package.
659fn with_search_checkouts<F>(checkouts_dir: PathBuf, package_name: &str, mut f: F) -> Result<()>
660where
661    F: FnMut(SourceIndex, PathBuf) -> Result<()>,
662{
663    for entry in fs::read_dir(checkouts_dir)? {
664        let entry = entry?;
665        let folder_name = entry
666            .file_name()
667            .into_string()
668            .map_err(|_| anyhow!("invalid folder name"))?;
669        if folder_name.starts_with(package_name) {
670            // Search if the dir we are looking starts with the name of our package
671            for repo_dir in fs::read_dir(entry.path())? {
672                // Iterate over all dirs inside the `name-***` directory and try to open repo from
673                // each dirs inside this one
674                let repo_dir = repo_dir
675                    .map_err(|e| anyhow!("Cannot find local repo at checkouts dir {}", e))?;
676                if repo_dir.file_type()?.is_dir() {
677                    // Get the path of the current repo
678                    let repo_dir_path = repo_dir.path();
679                    // Get the index file from the found path
680                    if let Ok(index_file) = fs::read_to_string(repo_dir_path.join(".forc_index")) {
681                        let index = serde_json::from_str(&index_file)?;
682                        f(index, repo_dir_path)?;
683                    }
684                }
685            }
686        }
687    }
688    Ok(())
689}
690
691#[test]
692fn test_source_git_pinned_parsing() {
693    let strings = [
694        "git+https://github.com/foo/bar?branch=baz#64092602dd6158f3e41d775ed889389440a2cd86",
695        "git+https://github.com/fuellabs/sway-lib-std?tag=v0.1.0#0000000000000000000000000000000000000000",
696        "git+https://some-git-host.com/owner/repo?rev#FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
697        "git+https://some-git-host.com/owner/repo?default-branch#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
698    ];
699
700    let expected = [
701        Pinned {
702            source: Source {
703                repo: Url::from_str("https://github.com/foo/bar").unwrap(),
704                reference: Reference::Branch("baz".to_string()),
705            },
706            commit_hash: "64092602dd6158f3e41d775ed889389440a2cd86".to_string(),
707        },
708        Pinned {
709            source: Source {
710                repo: Url::from_str("https://github.com/fuellabs/sway-lib-std").unwrap(),
711                reference: Reference::Tag("v0.1.0".to_string()),
712            },
713            commit_hash: "0000000000000000000000000000000000000000".to_string(),
714        },
715        Pinned {
716            source: Source {
717                repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
718                reference: Reference::Rev("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string()),
719            },
720            commit_hash: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string(),
721        },
722        Pinned {
723            source: Source {
724                repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
725                reference: Reference::DefaultBranch,
726            },
727            commit_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
728        },
729    ];
730
731    for (&string, expected) in strings.iter().zip(&expected) {
732        let parsed = Pinned::from_str(string).unwrap();
733        assert_eq!(&parsed, expected);
734        let serialized = expected.to_string();
735        assert_eq!(&serialized, string);
736    }
737}