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
451 let lfs_url = if lorry_spec.lfs == Some(true) {
455 let mut url = url_to_push_to.clone();
456 url.set_path(&format!("{}/info/lfs", url.path()));
457 Some(url)
458 } else {
459 None
460 };
461
462 let push = &crate::push::Push {
463 url: url_to_push_to,
464 ref_names: refs.iter().map(|s| s.0.as_str()).collect(),
465 config_path: &arguments.git_config_path,
466 lfs_url: lfs_url.as_ref(),
467 };
468
469 tracing::debug!("running Push command");
470 let push_refs = match execute(push, repository_path.as_path()).await {
471 Ok((stdout, _)) => get_refs(refs.len(), &stdout),
472 Err(err) => match err {
473 ExecutionError::IO { command, source } => {
474 tracing::warn!("Command failed to spawn: {:?}", source.to_string());
475 Err(Error::Io {
476 command: command.clone(),
477 })
478 }
479 ExecutionError::CommandError {
480 command: _,
481 status: _,
482 stderr: _,
483 stdout,
484 } => get_refs(refs.len(), &stdout),
485 },
486 }?;
487
488 Ok(MirrorStatus {
489 push_refs,
490 warnings: {
491 let mut warnings = vec![];
492 if !refs.iter().any(|refs| refs.0.contains("refs/heads")) {
493 warnings.push(Warning::OnlyRefTags)
494 }
495 warnings.extend(
496 missing
497 .iter()
498 .map(|pattern| Warning::NonMatchingRefSpecs {
499 pattern: pattern.0.to_string(),
500 })
501 .collect::<Vec<Warning>>(),
502 );
503 warnings
504 },
505 })
506}
507
508pub async fn try_mirror(
537 lorry_details: &LorrySpec,
538 lorry_name: &str,
539 downstream_url: &url::Url,
540 workspace: &workspace::Workspace,
541 arguments: &Arguments,
542) -> Result<MirrorStatus, Error> {
543 match lorry_details {
544 LorrySpec::Git(single_lorry) => {
545 tracing::info!("Ensuring local mirror is consistent with downstream");
546 crate::fetch::Fetch {
547 git_repo: workspace,
548 target_url: downstream_url,
549 use_git_binary: arguments.use_git_binary,
550 git_config_path: arguments.git_config_path.as_path(),
551 refs: None,
553 lfs: false,
554 }
555 .fetch()
556 .await?;
557 let target_url = single_lorry.url.clone();
558 let remote = Remote {
559 workspace,
560 use_git_binary: arguments.use_git_binary,
561 git_config_path: arguments.git_config_path.as_path(),
562 };
563 tracing::info!("Matching ref-patterns with refs on upstream");
564 let refs = remote.list(&target_url).await?;
565
566 tracing::info!("Ensuring local mirror default branch is consistent with upstream");
567 remote.set_head(&target_url).await?;
568
569 tracing::info!("Fetching upstream repository into local mirror");
570 let (matches, _) = parse_refs(
575 refs.0.as_slice(),
576 single_lorry.ref_patterns.as_deref(),
577 single_lorry.ignore_patterns.as_deref(),
578 )?;
579 tracing::info!("Upstream contains {} matching refs", matches.len());
580 crate::fetch::Fetch {
581 git_repo: workspace,
582 target_url: &target_url,
583 use_git_binary: arguments.use_git_binary,
584 git_config_path: arguments.git_config_path.as_path(),
585 refs: Some(matches.as_slice()),
586 lfs: single_lorry.lfs == Some(true),
587 }
588 .fetch()
589 .await?;
590
591 push_to_mirror_server(
592 single_lorry,
593 lorry_name,
594 downstream_url,
595 workspace,
596 arguments,
597 )
598 .await
599 }
600 LorrySpec::RawFiles(raw_files) => {
601 if matches!(downstream_url.scheme(), "file") {
602 tracing::warn!("Raw file mirrors are not supported for local downstream");
603 return Err(Error::RawFileNotSupported(lorry_name.to_string()));
604 }
605 if arguments.sha256sum_required {
607 let missing_sha256sums = raw_files.missing_sha256sums();
608 if !missing_sha256sums.is_empty() {
609 let mut message = String::default();
610 missing_sha256sums
611 .iter()
612 .for_each(|url| message.push_str(&format!("{url}\n")));
613 return Err(Error::Sha256sumsNotSpecified(message));
614 }
615 }
616 tracing::info!("Fetching raw files from downstream to ensure consistency");
617 let mut lfs_url = downstream_url.clone();
618 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
619 tracing::debug!("running FetchDownstreamRawFiles command");
620 let fetch_err = execute(
621 &raw_files::FetchDownstreamRawFiles {
622 url: downstream_url,
623 lfs_url: &lfs_url,
624 worktree: workspace.lfs_data_path().as_path(),
625 config_path: arguments.git_config_path.as_path(),
626 },
627 &workspace.repository_path(),
628 )
629 .await;
630 if let Err(err) = fetch_err {
639 tracing::warn!("Fetch failed but might not be an error: {}", err);
640 } else {
641 tracing::info!("Local mirror is consistent with downstream");
642 }
643 let importer = importer::Importer(raw_files.clone());
644 let helper = raw_files::Helper(workspace.clone());
645
646 helper
649 .initial_commit_if_missing(arguments.git_config_path.as_path())
650 .await?;
651
652 let modified = importer
654 .ensure(&workspace.lfs_data_path(), arguments)
655 .await?;
656 if modified {
657 helper
659 .import_data(arguments.git_config_path.as_path())
660 .await?;
661 } else {
662 tracing::info!("No new files were added or removed, nothing to do")
663 };
664
665 tracing::info!("Synchronizing local raw-file mirror to downstream");
666 let mut lfs_url = downstream_url.clone();
667 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
668 tracing::debug!("running PushRawFiles command");
669 execute(
670 &raw_files::PushRawFiles {
671 url: downstream_url,
672 lfs_url: &lfs_url,
673 worktree: workspace.lfs_data_path().as_path(),
674 config_path: arguments.git_config_path.as_path(),
675 },
676 workspace.repository_path().as_path(),
677 )
678 .await?;
679 Ok(MirrorStatus {
682 push_refs: PushRefs(vec![(DEFAULT_BRANCH_NAME.to_string(), RefStatus::NoPush)]),
683 warnings: vec![],
684 })
685 }
686 }
687}
688
689#[cfg(test)]
690mod test {
691 use super::*;
692
693 use git2::Repository;
694
695 use crate::local::LocalRepositoryBuilder;
696 use crate::test_server::{spawn_test_server, TestBuilder, TestRepo};
697
698 #[test]
699 fn test_parse_refs() {
700 let (refs, missing) = parse_refs(
701 &[
702 Ref::from("refs/heads/master"),
703 Ref::from("refs/tags/v1.0.0"),
704 Ref::from("refs/tags/v1.0.1"),
705 Ref::from("refs/tags/v1.0.2-rc1"),
706 Ref::from("some-random-string"),
707 ],
708 Some(&[
709 String::from("refs/heads/ma*"),
710 String::from("v1.0.1"),
711 String::from("refs/tags/v*"),
712 String::from("notgonnamatch"),
713 ]),
714 Some(&[
716 String::from("refs/tags/*-rc*"),
717 String::from("*-rc*"),
718 String::from("*ef*"), ]),
720 )
721 .unwrap();
722 assert!(refs.iter().any(|key| *key == "refs/heads/master".into()));
723 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
724 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.1".into()));
725 assert!(!refs.iter().any(|key| *key == "some-random-string".into()));
726 assert!(!refs.iter().any(|key| *key == "refs/tags/v1.0.2-rc1".into()));
727 assert!(*missing.first().unwrap() == Ref::from("notgonnamatch"));
728 assert!(missing.len() == 1);
729 }
730
731 #[test]
732 fn test_parse_refs_conflict() {
733 let (refs, missing) = parse_refs(
734 &[
735 Ref::from("refs/heads/master"),
736 Ref::from("refs/heads/v1.0.0"),
737 Ref::from("refs/tags/v1.0.0"),
738 ],
739 Some(&[String::from("v1.0.0")]),
740 Some(&[String::from("master")]), )
742 .unwrap();
743 assert!(refs.len() == 2);
744 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
745 assert!(refs.iter().any(|key| *key == "refs/heads/v1.0.0".into()));
746 assert!(missing.is_empty());
747 }
748
749 #[tokio::test]
750 async fn test_try_mirror() {
751 let upstream = TestBuilder::default().test_repo("hello.git");
752 let upstream_repo = upstream.test_repo.unwrap();
753
754 let downstream = TestBuilder::default().git_config().workspace("test-repo");
755 let downstream_repo = TestRepo((String::from("hello.git"), vec![]));
756 let repos_upstream_dir = upstream.dir.join("repos_upstream");
757 let repos_downstream_dir = downstream.dir.join("repos_downstream");
758 let upstream_address =
760 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
761 .await
762 .unwrap();
763 let downstream_address =
765 spawn_test_server(repos_downstream_dir.as_path(), &[downstream_repo.clone()])
766 .await
767 .unwrap();
768
769 try_mirror(
770 &LorrySpec::Git(SingleLorry {
771 url: upstream_repo.address(&upstream_address),
772 ref_patterns: None,
773 ignore_patterns: None,
774 lfs: None,
775 }),
776 "test_lorry",
777 &downstream_repo.address(&downstream_address),
778 &downstream.workspace.unwrap(),
779 &Arguments {
780 working_area: downstream.dir,
781 use_git_binary: Some(true),
782 git_config_path: downstream.git_config.unwrap(),
783 ..Default::default()
784 },
785 )
786 .await
787 .unwrap();
788
789 let repository = Repository::open_bare(repos_downstream_dir.join("hello.git")).unwrap();
791 let mut walk = repository.revwalk().unwrap();
792 walk.push_head().unwrap();
793 let last_commit_id = walk.next().unwrap().unwrap();
794 let last_commit = repository.find_commit(last_commit_id).unwrap();
795 let last_commit_message = last_commit.message().unwrap();
796 assert!(last_commit_message == "Test Commit: 1/1")
797 }
798
799 #[tokio::test]
800 async fn test_try_local_mirror() {
801 let upstream = TestBuilder::default().test_repo("hello.git");
802 let upstream_repo = upstream.test_repo.unwrap();
803
804 let downstream = TestBuilder::default().git_config().workspace("test-repo");
805 let downstream_repo = downstream.dir.join("mirrors");
806
807 LocalRepositoryBuilder::new(&downstream_repo)
808 .build("test")
809 .unwrap();
810
811 let repos_upstream_dir = upstream.dir.join("repos_upstream");
812 let upstream_address =
814 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
815 .await
816 .unwrap();
817
818 try_mirror(
820 &LorrySpec::Git(SingleLorry {
821 url: upstream_repo.address(&upstream_address),
822 ref_patterns: None,
823 ignore_patterns: None,
824 lfs: None,
825 }),
826 "test_lorry",
827 &Url::from_file_path(downstream_repo.join("test/git-repository")).unwrap(),
828 &downstream.workspace.unwrap(),
829 &Arguments {
830 working_area: downstream.dir,
831 use_git_binary: Some(true),
832 git_config_path: downstream.git_config.unwrap(),
833 ..Default::default()
834 },
835 )
836 .await
837 .unwrap();
838 }
839}