workerlib/
lib.rs

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