workerlib/
lib.rs

1//!
2//! This is the workerlib crate
3//!
4
5mod arguments;
6mod download;
7pub mod fetch;
8mod get_head;
9mod importer;
10pub mod local;
11mod ls_remote;
12mod push;
13mod raw_files;
14pub mod redact;
15mod remote;
16
17#[cfg(test)]
18mod test_server;
19
20pub mod execute;
21pub mod git_config;
22pub mod git_fsck;
23pub mod git_gc;
24pub mod lorry_specs;
25pub mod workspace;
26
27use crate::execute::{execute, Error as ExecutionError};
28use git2::{Error as Libgit2Error, Repository};
29use git_config::DEFAULT_GIT_BRANCH;
30use glob::Pattern;
31use lorry_specs::SingleLorry;
32use remote::Ref;
33use remote::Remote;
34use std::collections::BTreeMap;
35use thiserror::Error;
36use url::Url;
37
38pub use arguments::Arguments;
39pub use lorry_specs::extract_lorry_specs;
40pub use lorry_specs::LorrySpec;
41pub use push::RefStatus;
42
43/// Extra header exposed on Git fetch uperations to upstream servers and
44/// push operations on downstream.
45pub const LORRY_VERSION_HEADER: &str = concat!("Lorry-Version: ", env!("CARGO_PKG_VERSION"));
46
47/// Default branch if HEAD is missing or unresolvable from the target repository.
48pub const DEFAULT_BRANCH_NAME: &str = "main";
49
50/// Default ref is missing from the target repository.
51pub const DEFAULT_REF_NAME: &str = "refs/heads/main";
52
53/// Non-fatal error encountered when running a Lorry.
54#[derive(Clone, Debug)]
55pub enum Warning {
56    /// Anytime a refspec is specified but it doesn't match any refs in the
57    /// upstream repository.
58    NonMatchingRefSpecs {
59        /// Pattern from the configuration that did not match any refs
60        pattern: String,
61    },
62    /// If only tags have been pulled from upstream, the repo will look broken
63    /// so we display a warning.
64    OnlyRefTags,
65}
66// TODO: Perhaps add a feature to the web ui to suppress warnings.
67
68impl Warning {
69    /// Return a nicely formatted name
70    pub fn name(&self) -> String {
71        match self {
72            Warning::NonMatchingRefSpecs { pattern: _ } => String::from("NonMatchingRefSpec"),
73            Warning::OnlyRefTags => String::from("OnlyRefTags"),
74        }
75    }
76
77    /// Return the body of the error message
78    pub fn message(&self) -> String {
79        match self {
80            Warning::NonMatchingRefSpecs { pattern } => pattern.clone(),
81            Warning::OnlyRefTags => String::from(
82                "Only tags have been pulled from upstream, repository will appear empty",
83            ),
84        }
85    }
86}
87
88/// Results of a parsing refs from porcelain git push output
89///
90/// Example command:
91/// ```txt
92/// git push --porcelain origin 'refs/heads/*' 2>/dev/null`
93/// ```
94#[derive(Clone, Debug, Default)]
95pub struct PushRefs(pub Vec<(String, RefStatus)>);
96
97/// Contains the status of a single mirror operation
98#[derive(Default)]
99pub struct MirrorStatus {
100    /// Refs we have attempted to push to upstream.
101    pub push_refs: PushRefs,
102    /// Warnings for display on Lorry's Web UI e.g. NoMatchingRefSpecs.
103    pub warnings: Vec<Warning>,
104}
105
106/// Top-level error type containing all possible errors during a mirroring operation.
107#[derive(Error, Debug)]
108pub enum Error {
109    /// The upstream was a git repo. There was an error pulling from the remote.
110    #[error("Failed to fetch upstream: {0}")]
111    Fetch(#[from] fetch::Error),
112
113    /// Indicates that the job exceeded the time it was allocated in the
114    /// Lorry configuration
115    #[error("job exceeded the allocated timeout: {seconds}")]
116    TimeoutExceeded {
117        /// Display elapsed time
118        seconds: u64,
119    },
120
121    /// An error occurred that originated in the workspace module.
122    #[error("Workspace Error: {0}")]
123    WorkspaceError(#[from] workspace::Error),
124
125    /// An error occured when importing raw files into a working directory.
126    #[error("LFS Importer Error: {0}")]
127    LFSImporter(#[from] importer::Error),
128
129    /// Generic command execution error not specific to any particular module.
130    #[error("Command Execution Error: {0}")]
131    Command(#[from] ExecutionError),
132
133    /// Generic IO error
134    #[error("IO Error")]
135    Io {
136        /// Display command
137        command: String,
138    },
139    /// Tried to access a repository on the local filesystem as the downstream,
140    /// but the given path is not a valid one.
141    #[error(
142        "Downstream is specified as a path on the local filesystem, but the path is malformed."
143    )]
144    InvalidPath(String),
145
146    /// Problem parsing a downstream git url
147    #[error("Failed to construct path to downstream git repo")]
148    ParseError(#[from] url::ParseError),
149
150    /// Generic Libgit2 failure not specific to any particular module.
151    #[error("Internal git failure: {0}")]
152    Libgit2Error(#[from] Libgit2Error),
153
154    /// Indicates that 100% of the desired refs failed to push to the
155    /// downstream.
156    #[error("All ({n_attempted}) refspecs failed")]
157    AllRefspecsFailed {
158        /// Display refs
159        refs: PushRefs,
160        /// Number of attempted refs
161        n_attempted: i64,
162    },
163
164    /// Indicates some but not all of the desired refspecs were not able to
165    /// be pushed downstream for some reason.
166    #[error("{n_failed} refspecs failed")]
167    SomeRefspecsFailed {
168        /// Refs pushed to upstream
169        refs: PushRefs,
170        /// Number of failed refs
171        n_failed: i64,
172    },
173
174    /// Indicates a refspec was malformed
175    #[error("Invalid Glob: {pattern} - {error}")]
176    InvalidGlobPattern {
177        /// Invalid pattern
178        pattern: String,
179        /// Reason for refspec malformity
180        error: String,
181    },
182
183    /// An error related to raw-file management occurred.
184    #[error("Raw File Related Error: {0}")]
185    RawFiles(#[from] raw_files::Error),
186
187    /// Indicates none of the refs in the mirror configuration matches those
188    /// that were available in the repository.
189    #[error("No Matching Refspecs")]
190    NoMatchingRefspecs,
191
192    /// Indicates that a push command failed and we could not understand the
193    /// the reason why. This may be due to a downstream server error.
194    #[error("Cannot parse push output: {0}")]
195    CannotParsePushOutput(String),
196
197    /// Indicates that the server requires Sha256sum values for all aw-files
198    /// but the file did not have one specified.
199    #[error("Sha256sums are missing: \n{0}")]
200    Sha256sumsNotSpecified(String),
201
202    /// Indicates the ignore pattern was not a valid regular expression
203    #[error("Invalid ignore pattern: {0}")]
204    InvalidIgnorePattern(String),
205
206    /// Raw file mirrors are not supported
207    #[error("Raw file mirror skipped: {0}")]
208    RawFileNotSupported(String),
209
210    /// An operation has failed on a remote
211    #[error("Remote failed: {0}")]
212    Remote(#[from] remote::Error),
213}
214
215impl Error {
216    /// Return the underlying status code from the error if the error is the
217    /// result of an command execution failure otherwise return nothing.
218    #[allow(clippy::collapsible_match)]
219    pub fn status(&self) -> Option<i32> {
220        match self {
221            Error::Fetch(e) => match e {
222                fetch::Error::Command(e) => e.status(),
223                _ => None,
224            },
225            Error::Command(e) => match e {
226                ExecutionError::IO {
227                    command: _,
228                    source: _,
229                } => None,
230                ExecutionError::CommandError {
231                    command: _,
232                    status,
233                    stderr: _,
234                    stdout: _,
235                } => Some(status.code().unwrap_or(-1)),
236            },
237            _ => None,
238        }
239    }
240}
241
242/// Fetch [PushRefs] from the result of a git push operation
243///
244/// If there are any failed refs, an error is recorded - otherwise return
245/// the push refs from the result of parsing stdout.
246fn get_refs(n_refs: usize, stdout: &str) -> Result<PushRefs, Error> {
247    match crate::push::Push::parse_output(stdout) {
248        Ok(results) => {
249            let n_failed = results
250                .0
251                .iter()
252                .filter(|r| matches!(r.1, RefStatus::Rejected))
253                .count();
254            if n_failed == n_refs {
255                return Err(Error::AllRefspecsFailed {
256                    refs: results.clone(),
257                    n_attempted: n_refs as i64,
258                });
259            }
260            if n_failed > 0 {
261                Err(Error::SomeRefspecsFailed {
262                    refs: results.clone(),
263                    n_failed: n_failed as i64,
264                })
265            } else {
266                Ok(results)
267            }
268        }
269        Err(message) => {
270            tracing::warn!("Failed to parse git push output:\n{}", message);
271            Err(Error::CannotParsePushOutput(message))
272        }
273    }
274}
275
276/// Match refs ignoring refs/{heads,tags}/ unless they're explicitly specified
277fn match_ref(pattern: &Pattern, input: &str) -> bool {
278    let pattern_str = pattern.as_str();
279    if pattern_str.starts_with("refs/heads/") || pattern_str.starts_with("refs/tags/") {
280        pattern.matches(input)
281    } else {
282        pattern.matches(
283            input
284                .trim_start_matches("refs/heads/")
285                .trim_start_matches("refs/tags/"),
286        )
287    }
288}
289
290/// Given all of the refs return a vec of those with some matches and a vec of
291/// any patterns that didn't result in any matches at all.
292///
293/// # Example
294/// ```yaml
295///
296/// ref-patterns:
297/// - 'ma*'
298/// - 'refs/tags/*'
299/// ignore-patterns:
300/// - 'refs/tags/*rc*
301/// ```
302///
303/// -> Parses the ignore-patterns and ref-patterns into globs
304/// * Matches refs against ignore patterns, removing all matching refs
305/// * Matches valid against ref-patterns and returns a vector of matching refs.
306fn parse_refs(
307    refs: &[Ref],
308    ref_patterns: Option<&[String]>,
309    ignore_patterns: Option<&[String]>,
310) -> Result<(Vec<Ref>, Vec<Ref>), Error> {
311    let ignore_globs = ignore_patterns
312        .as_ref()
313        .map(|ignore_patterns| {
314            ignore_patterns
315                .iter()
316                .try_fold(Vec::new(), |mut accm, pattern| {
317                    let glob_pattern =
318                        Pattern::new(pattern).map_err(|e| Error::InvalidGlobPattern {
319                            pattern: pattern.clone(),
320                            error: e.to_string(),
321                        })?;
322                    accm.push(glob_pattern);
323                    Ok::<Vec<Pattern>, Error>(accm)
324                })
325        })
326        .transpose()?;
327
328    let refs: Vec<Ref> = refs
329        .iter()
330        .filter_map(|ref_name| {
331            if ignore_globs.as_ref().is_some_and(|ignore_globs| {
332                ignore_globs
333                    .iter()
334                    .any(|pattern| match_ref(pattern, &ref_name.0))
335            }) {
336                None
337            } else {
338                Some(ref_name.clone())
339            }
340        })
341        .collect();
342
343    if let Some(ref_specs) = ref_patterns {
344        let mut patterns_with_matches: BTreeMap<String, bool> = BTreeMap::new();
345        let globs: Vec<Pattern> = ref_specs.iter().try_fold(Vec::new(), |mut accm, pattern| {
346            match Pattern::new(pattern) {
347                Ok(regex) => {
348                    patterns_with_matches.insert(pattern.clone(), false);
349                    accm.push(regex);
350                    Ok(accm)
351                }
352                Err(e) => Err(Error::InvalidGlobPattern {
353                    pattern: pattern.clone(),
354                    error: e.to_string(),
355                }),
356            }
357        })?;
358
359        Ok((
360            refs.iter().fold(Vec::new(), |mut accm, ref_name| {
361                if let Some(matching_glob) = globs.iter().find_map(|glob_pattern| {
362                    if match_ref(glob_pattern, &ref_name.0) {
363                        Some(glob_pattern.to_string())
364                    } else {
365                        None
366                    }
367                }) {
368                    patterns_with_matches.insert(matching_glob, true);
369                    accm.push(ref_name.clone());
370                };
371                accm
372            }),
373            patterns_with_matches
374                .iter()
375                .filter_map(|(pattern, had_match)| {
376                    if !had_match {
377                        Some(Ref(pattern.clone()))
378                    } else {
379                        None
380                    }
381                })
382                .collect(),
383        ))
384    } else {
385        Ok((refs.to_vec(), vec![]))
386    }
387}
388
389/// Push a git based mirror to the downstream server
390///
391/// * If the downstream host is local, the default branch is set to match the
392///   HEAD of the associated workspace.
393///
394/// * refs on `repository` are parsed against the Lorry spec - valid refs are
395///   then pushed to the Lorry mirror server.
396///
397/// TODO: Add option to use libgit2 for push in addition to pull on git mirrors.
398async fn push_to_mirror_server(
399    lorry_spec: &SingleLorry,
400    lorry_name: &str,
401    url_to_push_to: &Url,
402    active_repo: &workspace::Workspace,
403    arguments: &arguments::Arguments,
404) -> Result<MirrorStatus, Error> {
405    tracing::debug!("Pushing {} to mirror at {:?}", lorry_name, &url_to_push_to);
406
407    // The workspace on disk that Lorry stores the result of fetch operation to
408    let repository = Repository::open(active_repo.repository_path())?;
409    // If we are pushing to a local downstream
410    if url_to_push_to.scheme() == "file" {
411        // This repo is being pushed to the local filesystem
412        let file = url_to_push_to
413            .to_file_path()
414            .map_err(|_| Error::InvalidPath(url_to_push_to.to_string()))?;
415
416        let local_repo = Repository::open(file)?;
417        let head = repository.head()?;
418        // Set remote HEAD on local mirror to match workspace.
419        local_repo.set_head(
420            head.name()
421                .unwrap_or(&format!("refs/heads/{DEFAULT_GIT_BRANCH}")),
422        )?;
423    }
424    let ref_names = repository
425        .references()?
426        .try_fold(Vec::new(), |mut accm, reference| {
427            let ref_name = match reference {
428                Ok(ref_name) => {
429                    let name = ref_name.name().unwrap();
430                    name.to_string()
431                }
432                Err(err) => return Err(err),
433            };
434            accm.push(Ref(ref_name));
435            Ok(accm)
436        })?;
437
438    let (refs, missing) = parse_refs(
439        ref_names.as_slice(),
440        lorry_spec.ref_patterns.as_deref(),
441        lorry_spec.ignore_patterns.as_deref(),
442    )?;
443    tracing::info!("pushing {} refs", refs.len());
444
445    if refs.is_empty() {
446        return Err(Error::NoMatchingRefspecs);
447    }
448
449    let repository_path = active_repo.repository_path();
450    let push = &crate::push::Push {
451        url: url_to_push_to,
452        ref_names: refs.iter().map(|s| s.0.as_str()).collect(),
453        config_path: &arguments.git_config_path,
454    };
455
456    tracing::debug!("running Push command");
457    let push_refs = match execute(push, repository_path.as_path()).await {
458        Ok((stdout, _)) => get_refs(refs.len(), &stdout),
459        Err(err) => match err {
460            ExecutionError::IO { command, source } => {
461                tracing::warn!("Command failed to spawn: {:?}", source.to_string());
462                Err(Error::Io {
463                    command: command.clone(),
464                })
465            }
466            ExecutionError::CommandError {
467                command: _,
468                status: _,
469                stderr: _,
470                stdout,
471            } => get_refs(refs.len(), &stdout),
472        },
473    }?;
474
475    Ok(MirrorStatus {
476        push_refs,
477        warnings: {
478            let mut warnings = vec![];
479            if !refs.iter().any(|refs| refs.0.contains("refs/heads")) {
480                warnings.push(Warning::OnlyRefTags)
481            }
482            warnings.extend(
483                missing
484                    .iter()
485                    .map(|pattern| Warning::NonMatchingRefSpecs {
486                        pattern: pattern.0.to_string(),
487                    })
488                    .collect::<Vec<Warning>>(),
489            );
490            warnings
491        },
492    })
493}
494
495/// Attempt to mirror a repository from an upstream host into the configured
496/// downstream server.
497///
498/// Rough outline of the order of operations is:
499///
500/// * Attempt to fetch repository data from the downstream into the working
501///   directory of the local mirror.
502///
503/// * Run git remote-ls on upstream to find a list of refs in the upstream.
504///
505/// * Fetch the upstream repository on top of the working directory of the
506///   local mirror applying new updates.
507///
508/// * Push the local mirror back into the downstream updating it with new refs.
509///
510/// Fetching the downstream repository initially ensures that Lorry can work
511/// in a stateless environment and will consider the downstream mirror the
512/// source of truth.
513///
514/// This workflow applies to both LFS and normal Git mirrors. In the case of
515/// LFS the raw files are also downloaded into the working directory prior to
516/// running download operations.
517///
518/// In the event that the downstream repository becomes corrupted in someway
519/// the procedure is to delete and reinitialize an empty repository in which
520/// case the upstream mirror will be re-imported from scratch.
521///
522/// TODO: This code can be factored out better
523pub async fn try_mirror(
524    lorry_details: &LorrySpec,
525    lorry_name: &str,
526    downstream_url: &url::Url,
527    workspace: &workspace::Workspace,
528    arguments: &Arguments,
529) -> Result<MirrorStatus, Error> {
530    match lorry_details {
531        LorrySpec::Git(single_lorry) => {
532            tracing::info!("Ensuring local mirror is consistent with downstream");
533            crate::fetch::Fetch {
534                git_repo: workspace,
535                target_url: downstream_url,
536                use_git_binary: arguments.use_git_binary,
537                git_config_path: arguments.git_config_path.as_path(),
538                // all refs considered in downstream
539                refs: None,
540            }
541            .fetch()
542            .await?;
543            let target_url = single_lorry.url.clone();
544            let remote = Remote {
545                workspace,
546                use_git_binary: arguments.use_git_binary,
547                git_config_path: arguments.git_config_path.as_path(),
548            };
549            tracing::info!("Matching ref-patterns with refs on upstream");
550            let refs = remote.list(&target_url).await?;
551
552            tracing::info!("Ensuring local mirror default branch is consistent with upstream");
553            remote.set_head(&target_url).await?;
554
555            tracing::info!("Fetching upstream repository into local mirror");
556            // match upstream's remote ls response but do not error yet since
557            // although the Lorry configuration may ask for refs which are
558            // missing, we still want to pull and push what is available into
559            // our downstream. push_to_mirror_server will flag any errors.
560            let (matches, _) = parse_refs(
561                refs.0.as_slice(),
562                single_lorry.ref_patterns.as_deref(),
563                single_lorry.ignore_patterns.as_deref(),
564            )?;
565            tracing::info!("Upstream contains {} matching refs", matches.len());
566            crate::fetch::Fetch {
567                git_repo: workspace,
568                target_url: &target_url,
569                use_git_binary: arguments.use_git_binary,
570                git_config_path: arguments.git_config_path.as_path(),
571                refs: Some(matches.as_slice()),
572            }
573            .fetch()
574            .await?;
575            push_to_mirror_server(
576                single_lorry,
577                lorry_name,
578                downstream_url,
579                workspace,
580                arguments,
581            )
582            .await
583        }
584        LorrySpec::RawFiles(raw_files) => {
585            if matches!(downstream_url.scheme(), "file") {
586                tracing::warn!("Raw file mirrors are not supported for local downstream");
587                return Err(Error::RawFileNotSupported(lorry_name.to_string()));
588            }
589            // check for missing sha256sums if configuration disallows them
590            if arguments.sha256sum_required {
591                let missing_sha256sums = raw_files.missing_sha256sums();
592                if !missing_sha256sums.is_empty() {
593                    let mut message = String::default();
594                    missing_sha256sums
595                        .iter()
596                        .for_each(|url| message.push_str(&format!("{url}\n")));
597                    return Err(Error::Sha256sumsNotSpecified(message));
598                }
599            }
600            tracing::info!("Fetching raw files from downstream to ensure consistency");
601            let mut lfs_url = downstream_url.clone();
602            lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
603            tracing::debug!("running FetchDownstreamRawFiles command");
604            let fetch_err = execute(
605                &raw_files::FetchDownstreamRawFiles {
606                    url: downstream_url,
607                    lfs_url: &lfs_url,
608                    worktree: workspace.lfs_data_path().as_path(),
609                    config_path: arguments.git_config_path.as_path(),
610                },
611                &workspace.repository_path(),
612            )
613            .await;
614            // TODO: I would prefer to explicitly detect if the repository
615            // exists or not but the code calling the Gitlab API needs work.
616            // BUG: Be aware that if the downstream fails for some other reason
617            // like the server being down that this will create a new
618            // repository and push its contents up causing a rejection error.
619            // If this happens then you need to manually delete the mirror
620            // directory and on the next run it will properly import the
621            // downstream sources.
622            if let Err(err) = fetch_err {
623                tracing::warn!("Fetch failed but might not be an error: {}", err);
624            } else {
625                tracing::info!("Local mirror is consistent with downstream");
626            }
627            let importer = importer::Importer(raw_files.clone());
628            let helper = raw_files::Helper(workspace.clone());
629
630            // Initialize the LFS repository if it hasn't been already creating
631            // the first commit which enables tracking of everything within.
632            helper
633                .initial_commit_if_missing(arguments.git_config_path.as_path())
634                .await?;
635
636            // Download any new raw files
637            let modified = importer
638                .ensure(&workspace.lfs_data_path(), arguments)
639                .await?;
640            if modified {
641                // Import new data that was just downloaded
642                helper
643                    .import_data(arguments.git_config_path.as_path())
644                    .await?;
645            } else {
646                tracing::info!("No new files were added or removed, nothing to do")
647            };
648
649            tracing::info!("Synchronizing local raw-file mirror to downstream");
650            let mut lfs_url = downstream_url.clone();
651            lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
652            tracing::debug!("running PushRawFiles command");
653            execute(
654                &raw_files::PushRawFiles {
655                    url: downstream_url,
656                    lfs_url: &lfs_url,
657                    worktree: workspace.lfs_data_path().as_path(),
658                    config_path: arguments.git_config_path.as_path(),
659                },
660                workspace.repository_path().as_path(),
661            )
662            .await?;
663            // LFS breaks porcelain git output so we just fake it since we only
664            // supporting syncing to a single branch anyway.
665            Ok(MirrorStatus {
666                push_refs: PushRefs(vec![(DEFAULT_BRANCH_NAME.to_string(), RefStatus::NoPush)]),
667                warnings: vec![],
668            })
669        }
670    }
671}
672
673#[cfg(test)]
674mod test {
675    use super::*;
676
677    use git2::Repository;
678
679    use crate::local::LocalRepositoryBuilder;
680    use crate::test_server::{spawn_test_server, TestBuilder, TestRepo};
681
682    #[test]
683    fn test_parse_refs() {
684        let (refs, missing) = parse_refs(
685            &[
686                Ref::from("refs/heads/master"),
687                Ref::from("refs/tags/v1.0.0"),
688                Ref::from("refs/tags/v1.0.1"),
689                Ref::from("refs/tags/v1.0.2-rc1"),
690                Ref::from("some-random-string"),
691            ],
692            Some(&[
693                String::from("refs/heads/ma*"),
694                String::from("v1.0.1"),
695                String::from("refs/tags/v*"),
696                String::from("notgonnamatch"),
697            ]),
698            // ignore RC releases
699            Some(&[
700                String::from("refs/tags/*-rc*"),
701                String::from("*-rc*"),
702                String::from("*ef*"), // Will not match refs/...
703            ]),
704        )
705        .unwrap();
706        assert!(refs.iter().any(|key| *key == "refs/heads/master".into()));
707        assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
708        assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.1".into()));
709        assert!(!refs.iter().any(|key| *key == "some-random-string".into()));
710        assert!(!refs.iter().any(|key| *key == "refs/tags/v1.0.2-rc1".into()));
711        assert!(*missing.first().unwrap() == Ref::from("notgonnamatch"));
712        assert!(missing.len() == 1);
713    }
714
715    #[test]
716    fn test_parse_refs_conflict() {
717        let (refs, missing) = parse_refs(
718            &[
719                Ref::from("refs/heads/master"),
720                Ref::from("refs/heads/v1.0.0"),
721                Ref::from("refs/tags/v1.0.0"),
722            ],
723            Some(&[String::from("v1.0.0")]),
724            Some(&[String::from("master")]), // pointless
725        )
726        .unwrap();
727        assert!(refs.len() == 2);
728        assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
729        assert!(refs.iter().any(|key| *key == "refs/heads/v1.0.0".into()));
730        assert!(missing.is_empty());
731    }
732
733    #[tokio::test]
734    async fn test_try_mirror() {
735        let upstream = TestBuilder::default().test_repo("hello.git");
736        let upstream_repo = upstream.test_repo.unwrap();
737
738        let downstream = TestBuilder::default().git_config().workspace("test-repo");
739        let downstream_repo = TestRepo((String::from("hello.git"), vec![]));
740        let repos_upstream_dir = upstream.dir.join("repos_upstream");
741        let repos_downstream_dir = downstream.dir.join("repos_downstream");
742        // Upstream server
743        let upstream_address =
744            spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
745                .await
746                .unwrap();
747        // Downstream server
748        let downstream_address =
749            spawn_test_server(repos_downstream_dir.as_path(), &[downstream_repo.clone()])
750                .await
751                .unwrap();
752
753        try_mirror(
754            &LorrySpec::Git(SingleLorry {
755                url: upstream_repo.address(&upstream_address),
756                ref_patterns: None,
757                ignore_patterns: None,
758                lfs: None,
759            }),
760            "test_lorry",
761            &downstream_repo.address(&downstream_address),
762            &downstream.workspace.unwrap(),
763            &Arguments {
764                working_area: downstream.dir,
765                use_git_binary: Some(true),
766                git_config_path: downstream.git_config.unwrap(),
767                ..Default::default()
768            },
769        )
770        .await
771        .unwrap();
772
773        // ensure that downstream contains the git from the upstream
774        let repository = Repository::open_bare(repos_downstream_dir.join("hello.git")).unwrap();
775        let mut walk = repository.revwalk().unwrap();
776        walk.push_head().unwrap();
777        let last_commit_id = walk.next().unwrap().unwrap();
778        let last_commit = repository.find_commit(last_commit_id).unwrap();
779        let last_commit_message = last_commit.message().unwrap();
780        assert!(last_commit_message == "Test Commit: 1/1")
781    }
782
783    #[tokio::test]
784    async fn test_try_local_mirror() {
785        let upstream = TestBuilder::default().test_repo("hello.git");
786        let upstream_repo = upstream.test_repo.unwrap();
787
788        let downstream = TestBuilder::default().git_config().workspace("test-repo");
789        let downstream_repo = downstream.dir.join("mirrors");
790
791        LocalRepositoryBuilder::new(&downstream_repo)
792            .build("test")
793            .unwrap();
794
795        let repos_upstream_dir = upstream.dir.join("repos_upstream");
796        // Upstream server
797        let upstream_address =
798            spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
799                .await
800                .unwrap();
801
802        // Expect to mirror ref/heads/<DEFAULT_GIT_BRANCH_NAME>
803        try_mirror(
804            &LorrySpec::Git(SingleLorry {
805                url: upstream_repo.address(&upstream_address),
806                ref_patterns: None,
807                ignore_patterns: None,
808                lfs: None,
809            }),
810            "test_lorry",
811            &Url::from_file_path(downstream_repo.join("test/git-repository")).unwrap(),
812            &downstream.workspace.unwrap(),
813            &Arguments {
814                working_area: downstream.dir,
815                use_git_binary: Some(true),
816                git_config_path: downstream.git_config.unwrap(),
817                ..Default::default()
818            },
819        )
820        .await
821        .unwrap();
822    }
823}