1mod 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
43pub const LORRY_VERSION_HEADER: &str = concat!("Lorry-Version: ", env!("CARGO_PKG_VERSION"));
46
47pub const DEFAULT_BRANCH_NAME: &str = "main";
49
50pub const DEFAULT_REF_NAME: &str = "refs/heads/main";
52
53#[derive(Clone, Debug)]
55pub enum Warning {
56 NonMatchingRefSpecs {
59 pattern: String,
61 },
62 OnlyRefTags,
65}
66impl Warning {
69 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 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#[derive(Clone, Debug, Default)]
95pub struct PushRefs(pub Vec<(String, RefStatus)>);
96
97#[derive(Default)]
99pub struct MirrorStatus {
100 pub push_refs: PushRefs,
102 pub warnings: Vec<Warning>,
104}
105
106#[derive(Error, Debug)]
108pub enum Error {
109 #[error("Failed to fetch upstream: {0}")]
111 Fetch(#[from] fetch::Error),
112
113 #[error("job exceeded the allocated timeout: {seconds}")]
116 TimeoutExceeded {
117 seconds: u64,
119 },
120
121 #[error("Workspace Error: {0}")]
123 WorkspaceError(#[from] workspace::Error),
124
125 #[error("LFS Importer Error: {0}")]
127 LFSImporter(#[from] importer::Error),
128
129 #[error("Command Execution Error: {0}")]
131 Command(#[from] ExecutionError),
132
133 #[error("IO Error")]
135 Io {
136 command: String,
138 },
139 #[error(
142 "Downstream is specified as a path on the local filesystem, but the path is malformed."
143 )]
144 InvalidPath(String),
145
146 #[error("Failed to construct path to downstream git repo")]
148 ParseError(#[from] url::ParseError),
149
150 #[error("Internal git failure: {0}")]
152 Libgit2Error(#[from] Libgit2Error),
153
154 #[error("All ({n_attempted}) refspecs failed")]
157 AllRefspecsFailed {
158 refs: PushRefs,
160 n_attempted: i64,
162 },
163
164 #[error("{n_failed} refspecs failed")]
167 SomeRefspecsFailed {
168 refs: PushRefs,
170 n_failed: i64,
172 },
173
174 #[error("Invalid Glob: {pattern} - {error}")]
176 InvalidGlobPattern {
177 pattern: String,
179 error: String,
181 },
182
183 #[error("Raw File Related Error: {0}")]
185 RawFiles(#[from] raw_files::Error),
186
187 #[error("No Matching Refspecs")]
190 NoMatchingRefspecs,
191
192 #[error("Cannot parse push output: {0}")]
195 CannotParsePushOutput(String),
196
197 #[error("Sha256sums are missing: \n{0}")]
200 Sha256sumsNotSpecified(String),
201
202 #[error("Invalid ignore pattern: {0}")]
204 InvalidIgnorePattern(String),
205
206 #[error("Raw file mirror skipped: {0}")]
208 RawFileNotSupported(String),
209
210 #[error("Remote failed: {0}")]
212 Remote(#[from] remote::Error),
213}
214
215impl Error {
216 #[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
242fn 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
276fn 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
290fn 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
389async 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 let repository = Repository::open(active_repo.repository_path())?;
409 if url_to_push_to.scheme() == "file" {
411 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 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
495pub 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 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 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 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 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 helper
633 .initial_commit_if_missing(arguments.git_config_path.as_path())
634 .await?;
635
636 let modified = importer
638 .ensure(&workspace.lfs_data_path(), arguments)
639 .await?;
640 if modified {
641 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 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 Some(&[
700 String::from("refs/tags/*-rc*"),
701 String::from("*-rc*"),
702 String::from("*ef*"), ]),
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")]), )
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 let upstream_address =
744 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
745 .await
746 .unwrap();
747 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 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 let upstream_address =
798 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
799 .await
800 .unwrap();
801
802 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}