1mod 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
45pub const LORRY_VERSION_HEADER: &str = concat!("Lorry-Version: ", env!("CARGO_PKG_VERSION"));
48
49pub const DEFAULT_BRANCH_NAME: &str = "main";
51
52pub 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#[derive(Clone, Debug)]
67pub enum Warning {
68 NonMatchingRefSpecs {
71 pattern: String,
73 },
74 OnlyRefTags,
77 LFSRefsSkipped,
80}
81impl Warning {
84 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 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#[derive(Clone, Debug, Default)]
114pub struct PushRefs(pub Vec<(String, RefStatus)>);
115
116#[derive(Default)]
118pub struct MirrorStatus {
119 pub push_refs: PushRefs,
121 pub warnings: Vec<Warning>,
123}
124
125#[derive(Error, Debug)]
127pub enum Error {
128 #[error("Failed to fetch upstream: {0}")]
130 Fetch(#[from] fetch::Error),
131
132 #[error("job exceeded the allocated timeout: {seconds}")]
135 TimeoutExceeded {
136 seconds: u64,
138 },
139
140 #[error("Workspace Error: {0}")]
142 WorkspaceError(#[from] workspace::Error),
143
144 #[error("LFS Importer Error: {0}")]
146 LFSImporter(#[from] importer::Error),
147
148 #[error("Command Execution Error: {0}")]
150 Command(#[from] ExecutionError),
151
152 #[error("IO Error")]
154 Io {
155 command: String,
157 },
158 #[error(
161 "Downstream is specified as a path on the local filesystem, but the path is malformed."
162 )]
163 InvalidPath(String),
164
165 #[error("Failed to construct path to downstream git repo")]
167 ParseError(#[from] url::ParseError),
168
169 #[error("Internal git failure: {0}")]
171 Libgit2Error(#[from] Libgit2Error),
172
173 #[error("All ({n_attempted}) refspecs failed")]
176 AllRefspecsFailed {
177 refs: PushRefs,
179 n_attempted: i64,
181 },
182
183 #[error("{n_failed} refspecs failed")]
186 SomeRefspecsFailed {
187 refs: PushRefs,
189 n_failed: i64,
191 },
192
193 #[error("Invalid Glob: {pattern} - {error}")]
195 InvalidGlobPattern {
196 pattern: String,
198 error: String,
200 },
201
202 #[error("Raw File Related Error: {0}")]
204 RawFiles(#[from] raw_files::Error),
205
206 #[error("No Matching Refspecs")]
209 NoMatchingRefspecs,
210
211 #[error("Cannot parse push output: {0}")]
214 CannotParsePushOutput(String),
215
216 #[error("Sha256sums are missing: \n{0}")]
219 Sha256sumsNotSpecified(String),
220
221 #[error("Invalid ignore pattern: {0}")]
223 InvalidIgnorePattern(String),
224
225 #[error("Raw file mirror skipped: {0}")]
227 RawFileNotSupported(String),
228
229 #[error("Remote failed: {0}")]
231 Remote(#[from] remote::Error),
232}
233
234impl Error {
235 #[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
261fn 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
295fn 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
309fn 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
408async 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 let repository = Repository::open(active_repo.repository_path())?;
429 if url_to_push_to.scheme() == "file" {
431 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 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 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
551pub 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 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 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 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 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 helper
692 .initial_commit_if_missing(arguments.git_config_path.as_path())
693 .await?;
694
695 let modified = importer
697 .ensure(&workspace.lfs_data_path(), arguments)
698 .await?;
699 if modified {
700 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 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 Some(&[
759 String::from("refs/tags/*-rc*"),
760 String::from("*-rc*"),
761 String::from("*ef*"), ]),
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")]), )
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 let upstream_address =
803 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
804 .await
805 .unwrap();
806 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 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 let upstream_address =
857 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
858 .await
859 .unwrap();
860
861 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}