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 std::collections::BTreeMap;
36use thiserror::Error;
37use url::Url;
38
39pub use arguments::Arguments;
40pub use lorry_specs::extract_lorry_specs;
41pub use lorry_specs::LorrySpec;
42pub use push::RefStatus;
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 LFSRefsSkipped,
69}
70impl Warning {
73 pub fn name(&self) -> String {
75 match self {
76 Warning::NonMatchingRefSpecs { pattern: _ } => String::from("NonMatchingRefSpec"),
77 Warning::OnlyRefTags => String::from("OnlyRefTags"),
78 Warning::LFSRefsSkipped => String::from("LFSRefsSkipped"),
79 }
80 }
81
82 pub fn message(&self) -> String {
84 match self {
85 Warning::NonMatchingRefSpecs { pattern } => pattern.clone(),
86 Warning::OnlyRefTags => String::from(
87 "Only tags have been pulled from upstream, repository will appear empty",
88 ),
89 Warning::LFSRefsSkipped => String::from(
90 "Upstream contains LFS refs, but lfs setting for the mirror has been set to false",
91 ),
92 }
93 }
94}
95
96#[derive(Clone, Debug, Default)]
103pub struct PushRefs(pub Vec<(String, RefStatus)>);
104
105#[derive(Default)]
107pub struct MirrorStatus {
108 pub push_refs: PushRefs,
110 pub warnings: Vec<Warning>,
112}
113
114#[derive(Error, Debug)]
116pub enum Error {
117 #[error("Failed to fetch upstream: {0}")]
119 Fetch(#[from] fetch::Error),
120
121 #[error("job exceeded the allocated timeout: {seconds}")]
124 TimeoutExceeded {
125 seconds: u64,
127 },
128
129 #[error("Workspace Error: {0}")]
131 WorkspaceError(#[from] workspace::Error),
132
133 #[error("LFS Importer Error: {0}")]
135 LFSImporter(#[from] importer::Error),
136
137 #[error("Command Execution Error: {0}")]
139 Command(#[from] ExecutionError),
140
141 #[error("IO Error")]
143 Io {
144 command: String,
146 },
147 #[error(
150 "Downstream is specified as a path on the local filesystem, but the path is malformed."
151 )]
152 InvalidPath(String),
153
154 #[error("Failed to construct path to downstream git repo")]
156 ParseError(#[from] url::ParseError),
157
158 #[error("Internal git failure: {0}")]
160 Libgit2Error(#[from] Libgit2Error),
161
162 #[error("All ({n_attempted}) refspecs failed")]
165 AllRefspecsFailed {
166 refs: PushRefs,
168 n_attempted: i64,
170 },
171
172 #[error("{n_failed} refspecs failed")]
175 SomeRefspecsFailed {
176 refs: PushRefs,
178 n_failed: i64,
180 },
181
182 #[error("Invalid Glob: {pattern} - {error}")]
184 InvalidGlobPattern {
185 pattern: String,
187 error: String,
189 },
190
191 #[error("Raw File Related Error: {0}")]
193 RawFiles(#[from] raw_files::Error),
194
195 #[error("No Matching Refspecs")]
198 NoMatchingRefspecs,
199
200 #[error("Cannot parse push output: {0}")]
203 CannotParsePushOutput(String),
204
205 #[error("Sha256sums are missing: \n{0}")]
208 Sha256sumsNotSpecified(String),
209
210 #[error("Invalid ignore pattern: {0}")]
212 InvalidIgnorePattern(String),
213
214 #[error("Raw file mirror skipped: {0}")]
216 RawFileNotSupported(String),
217
218 #[error("Remote failed: {0}")]
220 Remote(#[from] remote::Error),
221}
222
223impl Error {
224 #[allow(clippy::collapsible_match)]
227 pub fn status(&self) -> Option<i32> {
228 match self {
229 Error::Fetch(e) => match e {
230 fetch::Error::Command(e) => e.status(),
231 _ => None,
232 },
233 Error::Command(e) => match e {
234 ExecutionError::IO {
235 command: _,
236 source: _,
237 } => None,
238 ExecutionError::CommandError {
239 command: _,
240 status,
241 stderr: _,
242 stdout: _,
243 } => Some(status.code().unwrap_or(-1)),
244 },
245 _ => None,
246 }
247 }
248}
249
250fn get_refs(n_refs: usize, stdout: &str) -> Result<PushRefs, Error> {
255 match crate::push::Push::parse_output(stdout) {
256 Ok(results) => {
257 let n_failed = results
258 .0
259 .iter()
260 .filter(|r| matches!(r.1, RefStatus::Rejected))
261 .count();
262 if n_failed == n_refs {
263 return Err(Error::AllRefspecsFailed {
264 refs: results.clone(),
265 n_attempted: n_refs as i64,
266 });
267 }
268 if n_failed > 0 {
269 Err(Error::SomeRefspecsFailed {
270 refs: results.clone(),
271 n_failed: n_failed as i64,
272 })
273 } else {
274 Ok(results)
275 }
276 }
277 Err(message) => {
278 tracing::warn!("Failed to parse git push output:\n{}", message);
279 Err(Error::CannotParsePushOutput(message))
280 }
281 }
282}
283
284fn match_ref(pattern: &Pattern, input: &str) -> bool {
286 let pattern_str = pattern.as_str();
287 if pattern_str.starts_with("refs/heads/") || pattern_str.starts_with("refs/tags/") {
288 pattern.matches(input)
289 } else {
290 pattern.matches(
291 input
292 .trim_start_matches("refs/heads/")
293 .trim_start_matches("refs/tags/"),
294 )
295 }
296}
297
298fn parse_refs(
315 refs: &[Ref],
316 ref_patterns: Option<&[String]>,
317 ignore_patterns: Option<&[String]>,
318) -> Result<(Vec<Ref>, Vec<Ref>), Error> {
319 let ignore_globs = ignore_patterns
320 .as_ref()
321 .map(|ignore_patterns| {
322 ignore_patterns
323 .iter()
324 .try_fold(Vec::new(), |mut accm, pattern| {
325 let glob_pattern =
326 Pattern::new(pattern).map_err(|e| Error::InvalidGlobPattern {
327 pattern: pattern.clone(),
328 error: e.to_string(),
329 })?;
330 accm.push(glob_pattern);
331 Ok::<Vec<Pattern>, Error>(accm)
332 })
333 })
334 .transpose()?;
335
336 let refs: Vec<Ref> = refs
337 .iter()
338 .filter_map(|ref_name| {
339 if ignore_globs.as_ref().is_some_and(|ignore_globs| {
340 ignore_globs
341 .iter()
342 .any(|pattern| match_ref(pattern, &ref_name.0))
343 }) {
344 None
345 } else {
346 Some(ref_name.clone())
347 }
348 })
349 .collect();
350
351 if let Some(ref_specs) = ref_patterns {
352 let mut patterns_with_matches: BTreeMap<String, bool> = BTreeMap::new();
353 let globs: Vec<Pattern> = ref_specs.iter().try_fold(Vec::new(), |mut accm, pattern| {
354 match Pattern::new(pattern) {
355 Ok(regex) => {
356 patterns_with_matches.insert(pattern.clone(), false);
357 accm.push(regex);
358 Ok(accm)
359 }
360 Err(e) => Err(Error::InvalidGlobPattern {
361 pattern: pattern.clone(),
362 error: e.to_string(),
363 }),
364 }
365 })?;
366
367 Ok((
368 refs.iter().fold(Vec::new(), |mut accm, ref_name| {
369 if let Some(matching_glob) = globs.iter().find_map(|glob_pattern| {
370 if match_ref(glob_pattern, &ref_name.0) {
371 Some(glob_pattern.to_string())
372 } else {
373 None
374 }
375 }) {
376 patterns_with_matches.insert(matching_glob, true);
377 accm.push(ref_name.clone());
378 };
379 accm
380 }),
381 patterns_with_matches
382 .iter()
383 .filter_map(|(pattern, had_match)| {
384 if !had_match {
385 Some(Ref(pattern.clone()))
386 } else {
387 None
388 }
389 })
390 .collect(),
391 ))
392 } else {
393 Ok((refs.to_vec(), vec![]))
394 }
395}
396
397async fn push_to_mirror_server(
407 lorry_spec: &SingleLorry,
408 lorry_name: &str,
409 url_to_push_to: &Url,
410 active_repo: &workspace::Workspace,
411 arguments: &arguments::Arguments,
412) -> Result<MirrorStatus, Error> {
413 tracing::debug!("Pushing {} to mirror at {:?}", lorry_name, &url_to_push_to);
414 let mut warnings = vec![];
415
416 let repository = Repository::open(active_repo.repository_path())?;
418 if url_to_push_to.scheme() == "file" {
420 let file = url_to_push_to
422 .to_file_path()
423 .map_err(|_| Error::InvalidPath(url_to_push_to.to_string()))?;
424
425 let local_repo = Repository::open(file)?;
426 let head = repository.head()?;
427 local_repo.set_head(
429 head.name()
430 .unwrap_or(&format!("refs/heads/{DEFAULT_GIT_BRANCH}")),
431 )?;
432 }
433 let ref_names = repository
434 .references()?
435 .try_fold(Vec::new(), |mut accm, reference| {
436 let ref_name = match reference {
437 Ok(ref_name) => {
438 let name = ref_name.name().unwrap();
439 name.to_string()
440 }
441 Err(err) => return Err(err),
442 };
443 accm.push(Ref(ref_name));
444 Ok(accm)
445 })?;
446
447 let (refs, missing) = parse_refs(
448 ref_names.as_slice(),
449 lorry_spec.ref_patterns.as_deref(),
450 lorry_spec.ignore_patterns.as_deref(),
451 )?;
452 tracing::info!("pushing {} refs", refs.len());
453
454 if refs.is_empty() {
455 return Err(Error::NoMatchingRefspecs);
456 }
457
458 let repository_path = active_repo.repository_path();
459
460 let lfs_url = if lorry_spec.lfs == Some(true) {
464 let mut url = url_to_push_to.clone();
465 url.set_path(&format!("{}/info/lfs", url.path()));
466 Some(url)
467 } else {
468 None
469 };
470
471 let check_lfs_refs_command = crate::check_lfs_refs::CheckLFSRefs {
472 config_path: &arguments.git_config_path,
473 };
474
475 tracing::debug!("running CheckLFS command");
476 let lfs_refs_present = match execute(&check_lfs_refs_command, repository_path.as_path()).await {
477 Ok((stdout, _)) => crate::check_lfs_refs::CheckLFSRefs::parse_output(&stdout),
478 Err(err) => {
479 tracing::warn!("CheckLFS command failed: {:?}", err);
480 false
481 }
482 };
483
484 if lfs_refs_present && !matches!(lorry_spec.lfs, Some(true)) {
485 warnings.push(Warning::LFSRefsSkipped)
486 }
487
488 let push = &crate::push::Push {
489 url: url_to_push_to,
490 ref_names: refs.iter().map(|s| s.0.as_str()).collect(),
491 config_path: &arguments.git_config_path,
492 lfs_url: lfs_url.as_ref(),
493 };
494
495 tracing::debug!("running Push command");
496 let push_refs = match execute(push, repository_path.as_path()).await {
497 Ok((stdout, _)) => get_refs(refs.len(), &stdout),
498 Err(err) => match err {
499 ExecutionError::IO { command, source } => {
500 tracing::warn!("Command failed to spawn: {:?}", source.to_string());
501 Err(Error::Io {
502 command: command.clone(),
503 })
504 }
505 ExecutionError::CommandError {
506 command: _,
507 status: _,
508 stderr: _,
509 stdout,
510 } => get_refs(refs.len(), &stdout),
511 },
512 }?;
513
514 Ok(MirrorStatus {
515 push_refs,
516 warnings: {
517 if !refs.iter().any(|refs| refs.0.contains("refs/heads")) {
518 warnings.push(Warning::OnlyRefTags)
519 }
520 warnings.extend(
521 missing
522 .iter()
523 .map(|pattern| Warning::NonMatchingRefSpecs {
524 pattern: pattern.0.to_string(),
525 })
526 .collect::<Vec<Warning>>(),
527 );
528 warnings
529 },
530 })
531}
532
533pub async fn try_mirror(
562 lorry_details: &LorrySpec,
563 lorry_name: &str,
564 downstream_url: &url::Url,
565 workspace: &workspace::Workspace,
566 arguments: &Arguments,
567) -> Result<MirrorStatus, Error> {
568 match lorry_details {
569 LorrySpec::Git(single_lorry) => {
570 tracing::info!("Ensuring local mirror is consistent with downstream");
571 crate::fetch::Fetch {
572 git_repo: workspace,
573 target_url: downstream_url,
574 use_git_binary: arguments.use_git_binary,
575 git_config_path: arguments.git_config_path.as_path(),
576 refs: None,
578 lfs: false,
579 }
580 .fetch()
581 .await?;
582 let target_url = single_lorry.url.clone();
583 let remote = Remote {
584 workspace,
585 use_git_binary: arguments.use_git_binary,
586 git_config_path: arguments.git_config_path.as_path(),
587 };
588 tracing::info!("Matching ref-patterns with refs on upstream");
589 let refs = remote.list(&target_url).await?;
590
591 tracing::info!("Ensuring local mirror default branch is consistent with upstream");
592 remote.set_head(&target_url).await?;
593
594 tracing::info!("Fetching upstream repository into local mirror");
595 let (matches, _) = parse_refs(
600 refs.0.as_slice(),
601 single_lorry.ref_patterns.as_deref(),
602 single_lorry.ignore_patterns.as_deref(),
603 )?;
604 tracing::info!("Upstream contains {} matching refs", matches.len());
605 crate::fetch::Fetch {
606 git_repo: workspace,
607 target_url: &target_url,
608 use_git_binary: arguments.use_git_binary,
609 git_config_path: arguments.git_config_path.as_path(),
610 refs: Some(matches.as_slice()),
611 lfs: single_lorry.lfs == Some(true),
612 }
613 .fetch()
614 .await?;
615
616 push_to_mirror_server(
617 single_lorry,
618 lorry_name,
619 downstream_url,
620 workspace,
621 arguments,
622 )
623 .await
624 }
625 LorrySpec::RawFiles(raw_files) => {
626 if matches!(downstream_url.scheme(), "file") {
627 tracing::warn!("Raw file mirrors are not supported for local downstream");
628 return Err(Error::RawFileNotSupported(lorry_name.to_string()));
629 }
630 if arguments.sha256sum_required {
632 let missing_sha256sums = raw_files.missing_sha256sums();
633 if !missing_sha256sums.is_empty() {
634 let mut message = String::default();
635 missing_sha256sums
636 .iter()
637 .for_each(|url| message.push_str(&format!("{url}\n")));
638 return Err(Error::Sha256sumsNotSpecified(message));
639 }
640 }
641 tracing::info!("Fetching raw files from downstream to ensure consistency");
642 let mut lfs_url = downstream_url.clone();
643 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
644 tracing::debug!("running FetchDownstreamRawFiles command");
645 let fetch_err = execute(
646 &raw_files::FetchDownstreamRawFiles {
647 url: downstream_url,
648 lfs_url: &lfs_url,
649 worktree: workspace.lfs_data_path().as_path(),
650 config_path: arguments.git_config_path.as_path(),
651 },
652 &workspace.repository_path(),
653 )
654 .await;
655 if let Err(err) = fetch_err {
664 tracing::warn!("Fetch failed but might not be an error: {}", err);
665 } else {
666 tracing::info!("Local mirror is consistent with downstream");
667 }
668 let importer = importer::Importer(raw_files.clone());
669 let helper = raw_files::Helper(workspace.clone());
670
671 helper
674 .initial_commit_if_missing(arguments.git_config_path.as_path())
675 .await?;
676
677 let modified = importer
679 .ensure(&workspace.lfs_data_path(), arguments)
680 .await?;
681 if modified {
682 helper
684 .import_data(arguments.git_config_path.as_path())
685 .await?;
686 } else {
687 tracing::info!("No new files were added or removed, nothing to do")
688 };
689
690 tracing::info!("Synchronizing local raw-file mirror to downstream");
691 let mut lfs_url = downstream_url.clone();
692 lfs_url.set_path(&format!("{}/info/lfs", lfs_url.path()));
693 tracing::debug!("running PushRawFiles command");
694 execute(
695 &raw_files::PushRawFiles {
696 url: downstream_url,
697 lfs_url: &lfs_url,
698 worktree: workspace.lfs_data_path().as_path(),
699 config_path: arguments.git_config_path.as_path(),
700 },
701 workspace.repository_path().as_path(),
702 )
703 .await?;
704 Ok(MirrorStatus {
707 push_refs: PushRefs(vec![(DEFAULT_BRANCH_NAME.to_string(), RefStatus::NoPush)]),
708 warnings: vec![],
709 })
710 }
711 }
712}
713
714#[cfg(test)]
715mod test {
716 use super::*;
717
718 use git2::Repository;
719
720 use crate::local::LocalRepositoryBuilder;
721 use crate::test_server::{spawn_test_server, TestBuilder, TestRepo};
722
723 #[test]
724 fn test_parse_refs() {
725 let (refs, missing) = parse_refs(
726 &[
727 Ref::from("refs/heads/master"),
728 Ref::from("refs/tags/v1.0.0"),
729 Ref::from("refs/tags/v1.0.1"),
730 Ref::from("refs/tags/v1.0.2-rc1"),
731 Ref::from("some-random-string"),
732 ],
733 Some(&[
734 String::from("refs/heads/ma*"),
735 String::from("v1.0.1"),
736 String::from("refs/tags/v*"),
737 String::from("notgonnamatch"),
738 ]),
739 Some(&[
741 String::from("refs/tags/*-rc*"),
742 String::from("*-rc*"),
743 String::from("*ef*"), ]),
745 )
746 .unwrap();
747 assert!(refs.iter().any(|key| *key == "refs/heads/master".into()));
748 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
749 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.1".into()));
750 assert!(!refs.iter().any(|key| *key == "some-random-string".into()));
751 assert!(!refs.iter().any(|key| *key == "refs/tags/v1.0.2-rc1".into()));
752 assert!(*missing.first().unwrap() == Ref::from("notgonnamatch"));
753 assert!(missing.len() == 1);
754 }
755
756 #[test]
757 fn test_parse_refs_conflict() {
758 let (refs, missing) = parse_refs(
759 &[
760 Ref::from("refs/heads/master"),
761 Ref::from("refs/heads/v1.0.0"),
762 Ref::from("refs/tags/v1.0.0"),
763 ],
764 Some(&[String::from("v1.0.0")]),
765 Some(&[String::from("master")]), )
767 .unwrap();
768 assert!(refs.len() == 2);
769 assert!(refs.iter().any(|key| *key == "refs/tags/v1.0.0".into()));
770 assert!(refs.iter().any(|key| *key == "refs/heads/v1.0.0".into()));
771 assert!(missing.is_empty());
772 }
773
774 #[tokio::test]
775 async fn test_try_mirror() {
776 let upstream = TestBuilder::default().test_repo("hello.git");
777 let upstream_repo = upstream.test_repo.unwrap();
778
779 let downstream = TestBuilder::default().git_config().workspace("test-repo");
780 let downstream_repo = TestRepo((String::from("hello.git"), vec![]));
781 let repos_upstream_dir = upstream.dir.join("repos_upstream");
782 let repos_downstream_dir = downstream.dir.join("repos_downstream");
783 let upstream_address =
785 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
786 .await
787 .unwrap();
788 let downstream_address =
790 spawn_test_server(repos_downstream_dir.as_path(), &[downstream_repo.clone()])
791 .await
792 .unwrap();
793
794 try_mirror(
795 &LorrySpec::Git(SingleLorry {
796 url: upstream_repo.address(&upstream_address),
797 ref_patterns: None,
798 ignore_patterns: None,
799 lfs: None,
800 }),
801 "test_lorry",
802 &downstream_repo.address(&downstream_address),
803 &downstream.workspace.unwrap(),
804 &Arguments {
805 working_area: downstream.dir,
806 use_git_binary: Some(true),
807 git_config_path: downstream.git_config.unwrap(),
808 ..Default::default()
809 },
810 )
811 .await
812 .unwrap();
813
814 let repository = Repository::open_bare(repos_downstream_dir.join("hello.git")).unwrap();
816 let mut walk = repository.revwalk().unwrap();
817 walk.push_head().unwrap();
818 let last_commit_id = walk.next().unwrap().unwrap();
819 let last_commit = repository.find_commit(last_commit_id).unwrap();
820 let last_commit_message = last_commit.message().unwrap();
821 assert!(last_commit_message == "Test Commit: 1/1")
822 }
823
824 #[tokio::test]
825 async fn test_try_local_mirror() {
826 let upstream = TestBuilder::default().test_repo("hello.git");
827 let upstream_repo = upstream.test_repo.unwrap();
828
829 let downstream = TestBuilder::default().git_config().workspace("test-repo");
830 let downstream_repo = downstream.dir.join("mirrors");
831
832 LocalRepositoryBuilder::new(&downstream_repo)
833 .build("test")
834 .unwrap();
835
836 let repos_upstream_dir = upstream.dir.join("repos_upstream");
837 let upstream_address =
839 spawn_test_server(repos_upstream_dir.as_path(), &[upstream_repo.clone()])
840 .await
841 .unwrap();
842
843 try_mirror(
845 &LorrySpec::Git(SingleLorry {
846 url: upstream_repo.address(&upstream_address),
847 ref_patterns: None,
848 ignore_patterns: None,
849 lfs: None,
850 }),
851 "test_lorry",
852 &Url::from_file_path(downstream_repo.join("test/git-repository")).unwrap(),
853 &downstream.workspace.unwrap(),
854 &Arguments {
855 working_area: downstream.dir,
856 use_git_binary: Some(true),
857 git_config_path: downstream.git_config.unwrap(),
858 ..Default::default()
859 },
860 )
861 .await
862 .unwrap();
863 }
864}