1#![deny(missing_docs)]
6
7mod arguments;
8mod download;
9pub mod fetch;
10mod get_head;
11mod importer;
12pub mod local;
13mod ls_remote;
14mod push;
15mod raw_files;
16pub mod redact;
17mod remote;
18
19#[cfg(test)]
20mod test_server;
21
22pub mod execute;
23pub mod git_config;
24pub mod git_fsck;
25pub mod git_gc;
26pub mod lorry_specs;
27pub mod workspace;
28
29use crate::execute::{execute, Error as ExecutionError};
30use git2::{Error as Libgit2Error, Repository};
31use git_config::DEFAULT_GIT_BRANCH;
32use glob::Pattern;
33use lorry_specs::SingleLorry;
34use remote::Ref;
35use remote::Remote;
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;
43
44pub const LORRY_VERSION_HEADER: &str = concat!("Lorry-Version: ", env!("CARGO_PKG_VERSION"));
47
48pub const DEFAULT_BRANCH_NAME: &str = "main";
50
51pub const DEFAULT_REF_NAME: &str = "refs/heads/main";
53
54#[derive(Clone, Debug)]
56pub enum Warning {
57 NonMatchingRefSpecs {
60 pattern: String,
62 },
63 OnlyRefTags,
66}
67impl Warning {
70 pub fn name(&self) -> String {
72 match self {
73 Warning::NonMatchingRefSpecs { pattern: _ } => String::from("NonMatchingRefSpec"),
74 Warning::OnlyRefTags => String::from("OnlyRefTags"),
75 }
76 }
77
78 pub fn message(&self) -> String {
80 match self {
81 Warning::NonMatchingRefSpecs { pattern } => pattern.clone(),
82 Warning::OnlyRefTags => String::from(
83 "Only tags have been pulled from upstream, repository will appear empty",
84 ),
85 }
86 }
87}
88
89#[derive(Clone, Debug, Default)]
96pub struct PushRefs(pub Vec<(String, String)>, pub Vec<(String, String)>);
97
98#[derive(Default)]
100pub struct MirrorStatus {
101 pub push_refs: PushRefs,
103 pub warnings: Vec<Warning>,
105}
106
107#[derive(Error, Debug)]
109pub enum Error {
110 #[error("Failed to fetch upstream: {0}")]
112 Fetch(#[from] fetch::Error),
113
114 #[error("job exceeded the allocated timeout: {seconds}")]
117 TimeoutExceeded {
118 seconds: u64,
120 },
121
122 #[error("Workspace Error: {0}")]
124 WorkspaceError(#[from] workspace::Error),
125
126 #[error("LFS Importer Error: {0}")]
128 LFSImporter(#[from] importer::Error),
129
130 #[error("Command Execution Error: {0}")]
132 Command(#[from] ExecutionError),
133
134 #[error("IO Error")]
136 Io {
137 command: String,
139 },
140 #[error(
143 "Downstream is specified as a path on the local filesystem, but the path is malformed."
144 )]
145 InvalidPath(String),
146
147 #[error("Failed to construct path to downstream git repo")]
149 ParseError(#[from] url::ParseError),
150
151 #[error("Internal git failure: {0}")]
153 Libgit2Error(#[from] Libgit2Error),
154
155 #[error("All ({n_attempted}) refspecs failed")]
158 AllRefspecsFailed {
159 refs: PushRefs,
161 n_attempted: i64,
163 },
164
165 #[error("{n_failed} refspecs failed")]
168 SomeRefspecsFailed {
169 refs: PushRefs,
171 n_failed: i64,
173 },
174
175 #[error("Invalid Glob: {pattern} - {error}")]
177 InvalidGlobPattern {
178 pattern: String,
180 error: String,
182 },
183
184 #[error("Raw File Related Error: {0}")]
186 RawFiles(#[from] raw_files::Error),
187
188 #[error("No Matching Refspecs")]
191 NoMatchingRefspecs,
192
193 #[error("Cannot parse push output: {0}")]
196 CannotParsePushOutput(String),
197
198 #[error("Sha256sums are missing: \n{0}")]
201 Sha256sumsNotSpecified(String),
202
203 #[error("Invalid ignore pattern: {0}")]
205 InvalidIgnorePattern(String),
206
207 #[error("Raw file mirror skipped: {0}")]
209 RawFileNotSupported(String),
210
211 #[error("Remote failed: {0}")]
213 Remote(#[from] remote::Error),
214}
215
216impl Error {
217 #[allow(clippy::collapsible_match)]
220 pub fn status(&self) -> Option<i32> {
221 match self {
222 Error::Fetch(e) => match e {
223 fetch::Error::Command(e) => e.status(),
224 _ => None,
225 },
226 Error::Command(e) => match e {
227 ExecutionError::IO {
228 command: _,
229 source: _,
230 } => None,
231 ExecutionError::CommandError {
232 command: _,
233 status,
234 stderr: _,
235 stdout: _,
236 } => Some(status.code().unwrap_or(-1)),
237 },
238 _ => None,
239 }
240 }
241}
242
243fn get_refs(n_refs: usize, stdout: &str) -> Result<PushRefs, Error> {
248 match crate::push::Push::parse_output(stdout) {
249 Ok(results) => {
250 if results.1.len() == n_refs {
251 return Err(Error::AllRefspecsFailed {
252 refs: results.clone(),
253 n_attempted: n_refs as i64,
254 });
255 }
256 if !results.1.is_empty() {
257 Err(Error::SomeRefspecsFailed {
258 refs: results.clone(),
259 n_failed: results.1.len() as i64,
260 })
261 } else {
262 Ok(results)
263 }
264 }
265 Err(message) => {
266 tracing::warn!("Failed to parse git push output:\n{}", message);
267 Err(Error::CannotParsePushOutput(message))
268 }
269 }
270}
271
272fn match_ref(pattern: &Pattern, input: &str) -> bool {
274 let pattern_str = pattern.as_str();
275 if pattern_str.starts_with("refs/heads/") || pattern_str.starts_with("refs/tags/") {
276 pattern.matches(input)
277 } else {
278 pattern.matches(
279 input
280 .trim_start_matches("refs/heads/")
281 .trim_start_matches("refs/tags/"),
282 )
283 }
284}
285
286fn parse_refs(
303 refs: &[Ref],
304 ref_patterns: Option<&[String]>,
305 ignore_patterns: Option<&[String]>,
306) -> Result<(Vec<Ref>, Vec<Ref>), Error> {
307 let ignore_globs = ignore_patterns
308 .as_ref()
309 .map(|ignore_patterns| {
310 ignore_patterns
311 .iter()
312 .try_fold(Vec::new(), |mut accm, pattern| {
313 let glob_pattern =
314 Pattern::new(pattern).map_err(|e| Error::InvalidGlobPattern {
315 pattern: pattern.clone(),
316 error: e.to_string(),
317 })?;
318 accm.push(glob_pattern);
319 Ok::<Vec<Pattern>, Error>(accm)
320 })
321 })
322 .transpose()?;
323
324 let refs: Vec<Ref> = refs
325 .iter()
326 .filter_map(|ref_name| {
327 if ignore_globs.as_ref().is_some_and(|ignore_globs| {
328 ignore_globs
329 .iter()
330 .any(|pattern| match_ref(pattern, &ref_name.0))
331 }) {
332 None
333 } else {
334 Some(ref_name.clone())
335 }
336 })
337 .collect();
338
339 if let Some(ref_specs) = ref_patterns {
340 let mut patterns_with_matches: BTreeMap<String, bool> = BTreeMap::new();
341 let globs: Vec<Pattern> = ref_specs.iter().try_fold(Vec::new(), |mut accm, pattern| {
342 match Pattern::new(pattern) {
343 Ok(regex) => {
344 patterns_with_matches.insert(pattern.clone(), false);
345 accm.push(regex);
346 Ok(accm)
347 }
348 Err(e) => Err(Error::InvalidGlobPattern {
349 pattern: pattern.clone(),
350 error: e.to_string(),
351 }),
352 }
353 })?;
354
355 Ok((
356 refs.iter().fold(Vec::new(), |mut accm, ref_name| {
357 if let Some(matching_glob) = globs.iter().find_map(|glob_pattern| {
358 if match_ref(glob_pattern, &ref_name.0) {
359 Some(glob_pattern.to_string())
360 } else {
361 None
362 }
363 }) {
364 patterns_with_matches.insert(matching_glob, true);
365 accm.push(ref_name.clone());
366 };
367 accm
368 }),
369 patterns_with_matches
370 .iter()
371 .filter_map(|(pattern, had_match)| {
372 if !had_match {
373 Some(Ref(pattern.clone()))
374 } else {
375 None
376 }
377 })
378 .collect(),
379 ))
380 } else {
381 Ok((refs.to_vec(), vec![]))
382 }
383}
384
385#[allow(deprecated)]
395async fn push_to_mirror_server(
396 lorry_spec: &SingleLorry,
397 lorry_name: &str,
398 url_to_push_to: &Url,
399 active_repo: &workspace::Workspace,
400 arguments: &arguments::Arguments,
401) -> Result<MirrorStatus, Error> {
402 tracing::debug!("Pushing {} to mirror at {:?}", lorry_name, &url_to_push_to);
403
404 let repository = Repository::open(active_repo.repository_path())?;
406 if url_to_push_to.scheme() == "file" {
408 let file = url_to_push_to
410 .to_file_path()
411 .map_err(|_| Error::InvalidPath(url_to_push_to.to_string()))?;
412
413 let local_repo = Repository::open(file)?;
414 let head = repository.head()?;
415 local_repo.set_head(
417 head.name()
418 .unwrap_or(&format!("refs/heads/{DEFAULT_GIT_BRANCH}")),
419 )?;
420 }
421 let ref_names = repository
422 .references()?
423 .try_fold(Vec::new(), |mut accm, reference| {
424 let ref_name = match reference {
425 Ok(ref_name) => {
426 let name = ref_name.name().unwrap();
427 name.to_string()
428 }
429 Err(err) => return Err(err),
430 };
431 accm.push(Ref(ref_name));
432 Ok(accm)
433 })?;
434
435 let (refs, missing) = parse_refs(
436 ref_names.as_slice(),
437 lorry_spec.ref_patterns.as_deref(),
438 lorry_spec.ignore_patterns.as_deref(),
439 )?;
440 tracing::info!("pushing {} refs", refs.len());
441
442 if refs.is_empty() {
443 return Err(Error::NoMatchingRefspecs);
444 }
445
446 let repository_path = active_repo.repository_path();
447 let push = &crate::push::Push {
448 url: url_to_push_to,
449 ref_names: refs.iter().map(|s| s.0.as_str()).collect(),
450 config_path: &arguments.git_config_path,
451 };
452
453 tracing::debug!("running Push command");
454 let push_refs = match execute(push, repository_path.as_path()).await {
455 Ok((stdout, _)) => get_refs(refs.len(), &stdout),
456 Err(err) => match err {
457 ExecutionError::IO { command, source } => {
458 tracing::warn!("Command failed to spawn: {:?}", source.to_string());
459 Err(Error::Io {
460 command: command.clone(),
461 })
462 }
463 ExecutionError::CommandError {
464 command: _,
465 status: _,
466 stderr: _,
467 stdout,
468 } => get_refs(refs.len(), &stdout),
469 },
470 }?;
471
472 Ok(MirrorStatus {
473 push_refs,
474 warnings: {
475 let mut warnings = vec![];
476 if !refs.iter().any(|refs| refs.0.contains("refs/heads")) {
477 warnings.push(Warning::OnlyRefTags)
478 }
479 warnings.extend(
480 missing
481 .iter()
482 .map(|pattern| Warning::NonMatchingRefSpecs {
483 pattern: pattern.0.to_string(),
484 })
485 .collect::<Vec<Warning>>(),
486 );
487 warnings
488 },
489 })
490}
491
492pub async fn try_mirror(
521 lorry_details: &LorrySpec,
522 lorry_name: &str,
523 downstream_url: &url::Url,
524 workspace: &workspace::Workspace,
525 arguments: &Arguments,
526) -> Result<MirrorStatus, Error> {
527 match lorry_details {
528 LorrySpec::Git(single_lorry) => {
529 tracing::info!("Ensuring local mirror is consistent with downstream");
530 crate::fetch::Fetch {
531 git_repo: workspace,
532 target_url: downstream_url,
533 use_git_binary: arguments.use_git_binary,
534 git_config_path: arguments.git_config_path.as_path(),
535 refs: None,
537 }
538 .fetch()
539 .await?;
540 let target_url = url::Url::parse(single_lorry.url.as_str()).unwrap(); let remote = Remote {
542 workspace,
543 use_git_binary: arguments.use_git_binary,
544 git_config_path: arguments.git_config_path.as_path(),
545 };
546 tracing::info!("Matching ref-patterns with refs on upstream");
547 let refs = remote.list(&target_url).await?;
548
549 tracing::info!("Ensuring local mirror default branch is consistent with upstream");
550 remote.set_head(&target_url).await?;
551
552 tracing::info!("Fetching upstream repository into local mirror");
553 let (matches, _) = parse_refs(
558 refs.0.as_slice(),
559 single_lorry.ref_patterns.as_deref(),
560 single_lorry.ignore_patterns.as_deref(),
561 )?;
562 tracing::info!("Upstream contains {} matching refs", matches.len());
563 crate::fetch::Fetch {
564 git_repo: workspace,
565 target_url: &target_url,
566 use_git_binary: arguments.use_git_binary,
567 git_config_path: arguments.git_config_path.as_path(),
568 refs: Some(matches.as_slice()),
569 }
570 .fetch()
571 .await?;
572 push_to_mirror_server(
573 single_lorry,
574 lorry_name,
575 downstream_url,
576 workspace,
577 arguments,
578 )
579 .await
580 }
581 LorrySpec::RawFiles(raw_files) => {
582 if matches!(downstream_url.scheme(), "file") {
583 tracing::warn!("Raw file mirrors are not supported for local downstream");
584 return Err(Error::RawFileNotSupported(lorry_name.to_string()));
585 }
586 if arguments.sha256sum_required {
588 let missing_sha256sums = raw_files.missing_sha256sums();
589 if !missing_sha256sums.is_empty() {
590 let mut message = String::default();
591 missing_sha256sums
592 .iter()
593 .for_each(|url| message.push_str(&format!("{url}\n")));
594 return Err(Error::Sha256sumsNotSpecified(message));
595 }
596 }
597 tracing::info!("Fetching raw files from downstream to ensure consistency");
598 let mut lfs_url = downstream_url.clone();
599 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
600 tracing::debug!("running FetchDownstreamRawFiles command");
601 let fetch_err = execute(
602 &raw_files::FetchDownstreamRawFiles {
603 url: downstream_url,
604 lfs_url: &lfs_url,
605 worktree: workspace.lfs_data_path().as_path(),
606 config_path: arguments.git_config_path.as_path(),
607 },
608 &workspace.repository_path(),
609 )
610 .await;
611 if let Err(err) = fetch_err {
620 tracing::warn!("Fetch failed but might not be an error: {}", err);
621 } else {
622 tracing::info!("Local mirror is consistent with downstream");
623 }
624 let importer = importer::Importer(raw_files.clone());
625 let helper = raw_files::Helper(workspace.clone());
626
627 helper
630 .initial_commit_if_missing(arguments.git_config_path.as_path())
631 .await?;
632
633 let modified = importer
635 .ensure(&workspace.lfs_data_path(), arguments)
636 .await?;
637 if modified {
638 helper
640 .import_data(arguments.git_config_path.as_path())
641 .await?;
642 } else {
643 tracing::info!("No new files were added or removed, nothing to do")
644 };
645
646 tracing::info!("Synchronizing local raw-file mirror to downstream");
647 let mut lfs_url = downstream_url.clone();
648 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
649 tracing::debug!("running PushRawFiles command");
650 execute(
651 &raw_files::PushRawFiles {
652 url: downstream_url,
653 lfs_url: &lfs_url,
654 worktree: workspace.lfs_data_path().as_path(),
655 config_path: arguments.git_config_path.as_path(),
656 },
657 workspace.repository_path().as_path(),
658 )
659 .await?;
660 Ok(MirrorStatus {
663 push_refs: PushRefs(
664 vec![(
665 DEFAULT_BRANCH_NAME.to_string(),
666 String::from("raw files pushed successfully"),
667 )],
668 vec![],
669 ),
670 warnings: vec![],
671 })
672 }
673 }
674}
675
676#[cfg(test)]
677mod test {
678 use super::*;
679
680 use git2::Repository;
681
682 use crate::local::LocalRepositoryBuilder;
683 use crate::test_server::{spawn_test_server, TestBuilder, TestRepo};
684
685 #[test]
686 fn test_parse_refs() {
687 let (refs, missing) = parse_refs(
688 &[
689 Ref::from("refs/heads/master"),
690 Ref::from("refs/tags/v1.0.0"),
691 Ref::from("refs/tags/v1.0.1"),
692 Ref::from("refs/tags/v1.0.2-rc1"),
693 Ref::from("some-random-string"),
694 ],
695 Some(&[
696 String::from("refs/heads/ma*"),
697 String::from("v1.0.1"),
698 String::from("refs/tags/v*"),
699 String::from("notgonnamatch"),
700 ]),
701 Some(&[
703 String::from("refs/tags/*-rc*"),
704 String::from("*-rc*"),
705 String::from("*ef*"), ]),
707 )
708 .unwrap();
709 assert!(refs.iter().any(|key| *key == "refs/heads/master".into()));
710 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
711 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.1".into()));
712 assert!(!refs.iter().any(|key| *key == "some-random-string".into()));
713 assert!(!refs.iter().any(|key| *key == "refs/tags/v1.0.2-rc1".into()));
714 assert!(*missing.first().unwrap() == Ref::from("notgonnamatch"));
715 assert!(missing.len() == 1);
716 }
717
718 #[test]
719 fn test_parse_refs_conflict() {
720 let (refs, missing) = parse_refs(
721 &[
722 Ref::from("refs/heads/master"),
723 Ref::from("refs/heads/v1.0.0"),
724 Ref::from("refs/tags/v1.0.0"),
725 ],
726 Some(&[String::from("v1.0.0")]),
727 Some(&[String::from("master")]), )
729 .unwrap();
730 assert!(refs.len() == 2);
731 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
732 assert!(refs.iter().any(|key| *key == "refs/heads/v1.0.0".into()));
733 assert!(missing.is_empty());
734 }
735
736 #[tokio::test]
737 async fn test_try_mirror() {
738 let upstream = TestBuilder::default().test_repo("hello.git");
739 let upstream_repo = upstream.test_repo.unwrap();
740
741 let downstream = TestBuilder::default().git_config().workspace("test-repo");
742 let downstream_repo = TestRepo((String::from("hello.git"), vec![]));
743 let repos_upstream_dir = upstream.dir.join("repos_upstream");
744 let repos_downstream_dir = downstream.dir.join("repos_downstream");
745 let upstream_address =
747 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
748 .await
749 .unwrap();
750 let downstream_address =
752 spawn_test_server(repos_downstream_dir.as_path(), &[downstream_repo.clone()])
753 .await
754 .unwrap();
755
756 try_mirror(
757 &LorrySpec::Git(SingleLorry {
758 url: upstream_repo.address(&upstream_address).to_string(),
759 ..Default::default()
760 }),
761 "test_lorry",
762 &downstream_repo.address(&downstream_address),
763 &downstream.workspace.unwrap(),
764 &Arguments {
765 working_area: downstream.dir,
766 use_git_binary: Some(true),
767 git_config_path: downstream.git_config.unwrap(),
768 ..Default::default()
769 },
770 )
771 .await
772 .unwrap();
773
774 let repository = Repository::open_bare(repos_downstream_dir.join("hello.git")).unwrap();
776 let mut walk = repository.revwalk().unwrap();
777 walk.push_head().unwrap();
778 let last_commit_id = walk.next().unwrap().unwrap();
779 let last_commit = repository.find_commit(last_commit_id).unwrap();
780 let last_commit_message = last_commit.message().unwrap();
781 assert!(last_commit_message == "Test Commit: 1/1")
782 }
783
784 #[tokio::test]
785 async fn test_try_local_mirror() {
786 let upstream = TestBuilder::default().test_repo("hello.git");
787 let upstream_repo = upstream.test_repo.unwrap();
788
789 let downstream = TestBuilder::default().git_config().workspace("test-repo");
790 let downstream_repo = downstream.dir.join("mirrors");
791
792 LocalRepositoryBuilder::new(&downstream_repo)
793 .build("test")
794 .unwrap();
795
796 let repos_upstream_dir = upstream.dir.join("repos_upstream");
797 let upstream_address =
799 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
800 .await
801 .unwrap();
802
803 try_mirror(
805 &LorrySpec::Git(SingleLorry {
806 url: upstream_repo.address(&upstream_address).to_string(),
807 ..Default::default()
808 }),
809 "test_lorry",
810 &Url::from_file_path(downstream_repo.join("test/git-repository")).unwrap(),
811 &downstream.workspace.unwrap(),
812 &Arguments {
813 working_area: downstream.dir,
814 use_git_binary: Some(true),
815 git_config_path: downstream.git_config.unwrap(),
816 ..Default::default()
817 },
818 )
819 .await
820 .unwrap();
821 }
822}