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
389#[allow(deprecated)]
399async fn push_to_mirror_server(
400 lorry_spec: &SingleLorry,
401 lorry_name: &str,
402 url_to_push_to: &Url,
403 active_repo: &workspace::Workspace,
404 arguments: &arguments::Arguments,
405) -> Result<MirrorStatus, Error> {
406 tracing::debug!("Pushing {} to mirror at {:?}", lorry_name, &url_to_push_to);
407
408 let repository = Repository::open(active_repo.repository_path())?;
410 if url_to_push_to.scheme() == "file" {
412 let file = url_to_push_to
414 .to_file_path()
415 .map_err(|_| Error::InvalidPath(url_to_push_to.to_string()))?;
416
417 let local_repo = Repository::open(file)?;
418 let head = repository.head()?;
419 local_repo.set_head(
421 head.name()
422 .unwrap_or(&format!("refs/heads/{DEFAULT_GIT_BRANCH}")),
423 )?;
424 }
425 let ref_names = repository
426 .references()?
427 .try_fold(Vec::new(), |mut accm, reference| {
428 let ref_name = match reference {
429 Ok(ref_name) => {
430 let name = ref_name.name().unwrap();
431 name.to_string()
432 }
433 Err(err) => return Err(err),
434 };
435 accm.push(Ref(ref_name));
436 Ok(accm)
437 })?;
438
439 let (refs, missing) = parse_refs(
440 ref_names.as_slice(),
441 lorry_spec.ref_patterns.as_deref(),
442 lorry_spec.ignore_patterns.as_deref(),
443 )?;
444 tracing::info!("pushing {} refs", refs.len());
445
446 if refs.is_empty() {
447 return Err(Error::NoMatchingRefspecs);
448 }
449
450 let repository_path = active_repo.repository_path();
451 let push = &crate::push::Push {
452 url: url_to_push_to,
453 ref_names: refs.iter().map(|s| s.0.as_str()).collect(),
454 config_path: &arguments.git_config_path,
455 };
456
457 tracing::debug!("running Push command");
458 let push_refs = match execute(push, repository_path.as_path()).await {
459 Ok((stdout, _)) => get_refs(refs.len(), &stdout),
460 Err(err) => match err {
461 ExecutionError::IO { command, source } => {
462 tracing::warn!("Command failed to spawn: {:?}", source.to_string());
463 Err(Error::Io {
464 command: command.clone(),
465 })
466 }
467 ExecutionError::CommandError {
468 command: _,
469 status: _,
470 stderr: _,
471 stdout,
472 } => get_refs(refs.len(), &stdout),
473 },
474 }?;
475
476 Ok(MirrorStatus {
477 push_refs,
478 warnings: {
479 let mut warnings = vec![];
480 if !refs.iter().any(|refs| refs.0.contains("refs/heads")) {
481 warnings.push(Warning::OnlyRefTags)
482 }
483 warnings.extend(
484 missing
485 .iter()
486 .map(|pattern| Warning::NonMatchingRefSpecs {
487 pattern: pattern.0.to_string(),
488 })
489 .collect::<Vec<Warning>>(),
490 );
491 warnings
492 },
493 })
494}
495
496pub async fn try_mirror(
525 lorry_details: &LorrySpec,
526 lorry_name: &str,
527 downstream_url: &url::Url,
528 workspace: &workspace::Workspace,
529 arguments: &Arguments,
530) -> Result<MirrorStatus, Error> {
531 match lorry_details {
532 LorrySpec::Git(single_lorry) => {
533 tracing::info!("Ensuring local mirror is consistent with downstream");
534 crate::fetch::Fetch {
535 git_repo: workspace,
536 target_url: downstream_url,
537 use_git_binary: arguments.use_git_binary,
538 git_config_path: arguments.git_config_path.as_path(),
539 refs: None,
541 }
542 .fetch()
543 .await?;
544 let target_url = url::Url::parse(single_lorry.url.as_str()).unwrap(); let remote = Remote {
546 workspace,
547 use_git_binary: arguments.use_git_binary,
548 git_config_path: arguments.git_config_path.as_path(),
549 };
550 tracing::info!("Matching ref-patterns with refs on upstream");
551 let refs = remote.list(&target_url).await?;
552
553 tracing::info!("Ensuring local mirror default branch is consistent with upstream");
554 remote.set_head(&target_url).await?;
555
556 tracing::info!("Fetching upstream repository into local mirror");
557 let (matches, _) = parse_refs(
562 refs.0.as_slice(),
563 single_lorry.ref_patterns.as_deref(),
564 single_lorry.ignore_patterns.as_deref(),
565 )?;
566 tracing::info!("Upstream contains {} matching refs", matches.len());
567 crate::fetch::Fetch {
568 git_repo: workspace,
569 target_url: &target_url,
570 use_git_binary: arguments.use_git_binary,
571 git_config_path: arguments.git_config_path.as_path(),
572 refs: Some(matches.as_slice()),
573 }
574 .fetch()
575 .await?;
576 push_to_mirror_server(
577 single_lorry,
578 lorry_name,
579 downstream_url,
580 workspace,
581 arguments,
582 )
583 .await
584 }
585 LorrySpec::RawFiles(raw_files) => {
586 if matches!(downstream_url.scheme(), "file") {
587 tracing::warn!("Raw file mirrors are not supported for local downstream");
588 return Err(Error::RawFileNotSupported(lorry_name.to_string()));
589 }
590 if arguments.sha256sum_required {
592 let missing_sha256sums = raw_files.missing_sha256sums();
593 if !missing_sha256sums.is_empty() {
594 let mut message = String::default();
595 missing_sha256sums
596 .iter()
597 .for_each(|url| message.push_str(&format!("{url}\n")));
598 return Err(Error::Sha256sumsNotSpecified(message));
599 }
600 }
601 tracing::info!("Fetching raw files from downstream to ensure consistency");
602 let mut lfs_url = downstream_url.clone();
603 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
604 tracing::debug!("running FetchDownstreamRawFiles command");
605 let fetch_err = execute(
606 &raw_files::FetchDownstreamRawFiles {
607 url: downstream_url,
608 lfs_url: &lfs_url,
609 worktree: workspace.lfs_data_path().as_path(),
610 config_path: arguments.git_config_path.as_path(),
611 },
612 &workspace.repository_path(),
613 )
614 .await;
615 if let Err(err) = fetch_err {
624 tracing::warn!("Fetch failed but might not be an error: {}", err);
625 } else {
626 tracing::info!("Local mirror is consistent with downstream");
627 }
628 let importer = importer::Importer(raw_files.clone());
629 let helper = raw_files::Helper(workspace.clone());
630
631 helper
634 .initial_commit_if_missing(arguments.git_config_path.as_path())
635 .await?;
636
637 let modified = importer
639 .ensure(&workspace.lfs_data_path(), arguments)
640 .await?;
641 if modified {
642 helper
644 .import_data(arguments.git_config_path.as_path())
645 .await?;
646 } else {
647 tracing::info!("No new files were added or removed, nothing to do")
648 };
649
650 tracing::info!("Synchronizing local raw-file mirror to downstream");
651 let mut lfs_url = downstream_url.clone();
652 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
653 tracing::debug!("running PushRawFiles command");
654 execute(
655 &raw_files::PushRawFiles {
656 url: downstream_url,
657 lfs_url: &lfs_url,
658 worktree: workspace.lfs_data_path().as_path(),
659 config_path: arguments.git_config_path.as_path(),
660 },
661 workspace.repository_path().as_path(),
662 )
663 .await?;
664 Ok(MirrorStatus {
667 push_refs: PushRefs(vec![(DEFAULT_BRANCH_NAME.to_string(), RefStatus::NoPush)]),
668 warnings: vec![],
669 })
670 }
671 }
672}
673
674#[cfg(test)]
675mod test {
676 use super::*;
677
678 use git2::Repository;
679
680 use crate::local::LocalRepositoryBuilder;
681 use crate::test_server::{spawn_test_server, TestBuilder, TestRepo};
682
683 #[test]
684 fn test_parse_refs() {
685 let (refs, missing) = parse_refs(
686 &[
687 Ref::from("refs/heads/master"),
688 Ref::from("refs/tags/v1.0.0"),
689 Ref::from("refs/tags/v1.0.1"),
690 Ref::from("refs/tags/v1.0.2-rc1"),
691 Ref::from("some-random-string"),
692 ],
693 Some(&[
694 String::from("refs/heads/ma*"),
695 String::from("v1.0.1"),
696 String::from("refs/tags/v*"),
697 String::from("notgonnamatch"),
698 ]),
699 Some(&[
701 String::from("refs/tags/*-rc*"),
702 String::from("*-rc*"),
703 String::from("*ef*"), ]),
705 )
706 .unwrap();
707 assert!(refs.iter().any(|key| *key == "refs/heads/master".into()));
708 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
709 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.1".into()));
710 assert!(!refs.iter().any(|key| *key == "some-random-string".into()));
711 assert!(!refs.iter().any(|key| *key == "refs/tags/v1.0.2-rc1".into()));
712 assert!(*missing.first().unwrap() == Ref::from("notgonnamatch"));
713 assert!(missing.len() == 1);
714 }
715
716 #[test]
717 fn test_parse_refs_conflict() {
718 let (refs, missing) = parse_refs(
719 &[
720 Ref::from("refs/heads/master"),
721 Ref::from("refs/heads/v1.0.0"),
722 Ref::from("refs/tags/v1.0.0"),
723 ],
724 Some(&[String::from("v1.0.0")]),
725 Some(&[String::from("master")]), )
727 .unwrap();
728 assert!(refs.len() == 2);
729 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
730 assert!(refs.iter().any(|key| *key == "refs/heads/v1.0.0".into()));
731 assert!(missing.is_empty());
732 }
733
734 #[tokio::test]
735 async fn test_try_mirror() {
736 let upstream = TestBuilder::default().test_repo("hello.git");
737 let upstream_repo = upstream.test_repo.unwrap();
738
739 let downstream = TestBuilder::default().git_config().workspace("test-repo");
740 let downstream_repo = TestRepo((String::from("hello.git"), vec![]));
741 let repos_upstream_dir = upstream.dir.join("repos_upstream");
742 let repos_downstream_dir = downstream.dir.join("repos_downstream");
743 let upstream_address =
745 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
746 .await
747 .unwrap();
748 let downstream_address =
750 spawn_test_server(repos_downstream_dir.as_path(), &[downstream_repo.clone()])
751 .await
752 .unwrap();
753
754 try_mirror(
755 &LorrySpec::Git(SingleLorry {
756 url: upstream_repo.address(&upstream_address).to_string(),
757 ..Default::default()
758 }),
759 "test_lorry",
760 &downstream_repo.address(&downstream_address),
761 &downstream.workspace.unwrap(),
762 &Arguments {
763 working_area: downstream.dir,
764 use_git_binary: Some(true),
765 git_config_path: downstream.git_config.unwrap(),
766 ..Default::default()
767 },
768 )
769 .await
770 .unwrap();
771
772 let repository = Repository::open_bare(repos_downstream_dir.join("hello.git")).unwrap();
774 let mut walk = repository.revwalk().unwrap();
775 walk.push_head().unwrap();
776 let last_commit_id = walk.next().unwrap().unwrap();
777 let last_commit = repository.find_commit(last_commit_id).unwrap();
778 let last_commit_message = last_commit.message().unwrap();
779 assert!(last_commit_message == "Test Commit: 1/1")
780 }
781
782 #[tokio::test]
783 async fn test_try_local_mirror() {
784 let upstream = TestBuilder::default().test_repo("hello.git");
785 let upstream_repo = upstream.test_repo.unwrap();
786
787 let downstream = TestBuilder::default().git_config().workspace("test-repo");
788 let downstream_repo = downstream.dir.join("mirrors");
789
790 LocalRepositoryBuilder::new(&downstream_repo)
791 .build("test")
792 .unwrap();
793
794 let repos_upstream_dir = upstream.dir.join("repos_upstream");
795 let upstream_address =
797 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
798 .await
799 .unwrap();
800
801 try_mirror(
803 &LorrySpec::Git(SingleLorry {
804 url: upstream_repo.address(&upstream_address).to_string(),
805 ..Default::default()
806 }),
807 "test_lorry",
808 &Url::from_file_path(downstream_repo.join("test/git-repository")).unwrap(),
809 &downstream.workspace.unwrap(),
810 &Arguments {
811 working_area: downstream.dir,
812 use_git_binary: Some(true),
813 git_config_path: downstream.git_config.unwrap(),
814 ..Default::default()
815 },
816 )
817 .await
818 .unwrap();
819 }
820}