1use std::collections::HashSet;
7use std::sync::Arc;
8
9use xianyun_backends::git::{Backend as GitBackend, GitError, RepositoryState};
10use xianyun_core::config::container::Container;
11use xianyun_core::config::latest::CaproniConfig;
12use xianyun_executor::exec::CommandRunner;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum UpdateStatus {
19 #[default]
20 Updated,
21 Diverged,
22 Dirty,
23 Detached,
24 Warning,
25 Failed,
26 Skipped,
27 Pinned,
28}
29
30impl std::fmt::Display for UpdateStatus {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 let s = match self {
33 UpdateStatus::Updated => "Updated",
34 UpdateStatus::Diverged => "Diverged",
35 UpdateStatus::Dirty => "Dirty",
36 UpdateStatus::Detached => "Detached",
37 UpdateStatus::Warning => "Warning",
38 UpdateStatus::Failed => "Failed",
39 UpdateStatus::Skipped => "Skipped",
40 UpdateStatus::Pinned => "Pinned",
41 };
42
43 write!(f, "{s}")
44 }
45}
46
47#[derive(Debug, Default)]
49pub struct UpdateResult {
50 pub repo_name: String,
51 pub status: UpdateStatus,
52 pub branch: String,
53 pub default_branch: String,
54 pub ref_: String,
55 pub commits_behind: i64,
56 pub commits_ahead: i64,
57 pub tracking_branch: String,
58 pub notes: String,
59 pub error: Option<GitError>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum UpdateStrategy {
65 #[default]
66 FullUpdate,
67 MinimalRequired,
68 VerifyOnly,
69}
70
71pub type RepoFilterFn = Box<dyn Fn(&str) -> bool + Send + Sync>;
73
74#[derive(Default)]
76pub struct UpdateOptions {
77 pub strategy: UpdateStrategy,
78 pub stash: bool,
79 pub filter: Option<RepoFilterFn>,
80 pub quiet: bool,
81 pub skip_prepare_hooks: bool,
82}
83
84#[must_use]
88pub fn determine_required_repositories(caproni_cfg: &CaproniConfig) -> HashSet<String> {
89 let mut required = HashSet::new();
90
91 if let Some(repositories) = &caproni_cfg.repositories {
92 for (name, repo_config) in repositories.iter() {
93 if repo_config.edit_mode {
94 required.insert(name.clone());
95 }
96 }
97 }
98
99 if let Some(deployers) = &caproni_cfg.deployers {
100 for deployer_config in deployers.values() {
101 if deployer_config.edit_mode
102 && let Some(repository) = &deployer_config.repository
103 {
104 required.insert(repository.clone());
105 }
106 }
107 }
108
109 if let Some(reloaders) = &caproni_cfg.reloaders {
110 for reloader_config in reloaders.values() {
111 if reloader_config.edit_mode {
112 required.insert(reloader_config.repository.clone());
113 }
114 }
115 }
116
117 required
118}
119
120pub struct Updater {
122 backend: Arc<dyn GitBackend + Send + Sync>,
123 options: UpdateOptions,
124}
125
126impl Updater {
127 pub fn new(backend: Arc<dyn GitBackend + Send + Sync>, options: UpdateOptions) -> Self {
129 Self { backend, options }
130 }
131
132 pub fn update_repositories(
141 &self,
142 exec: &dyn CommandRunner,
143 cfg: &Container,
144 ) -> (Vec<UpdateResult>, bool) {
145 let mut results = Vec::new();
146 let mut success = true;
147
148 let required_repos = determine_required_repositories(&cfg.caproni);
150
151 let Some(repositories) = &cfg.caproni.repositories else {
152 return (results, success);
153 };
154
155 for (name, repo_config) in repositories.iter() {
156 let mut result =
157 self.update_repository(name, repo_config, &cfg.caproni, &required_repos);
158
159 if (result.status == UpdateStatus::Updated || result.status == UpdateStatus::Pinned)
160 && !self.options.skip_prepare_hooks
161 {
162 let hooks = crate::lifecyclehooks::resolve_repository_hooks(
163 &cfg.caproni,
164 name,
165 crate::lifecyclehooks::HookType::EditModePrepare,
166 );
167
168 let hook_result = crate::lifecyclehooks::run_hooks(
169 exec,
170 &cfg.caproni,
171 cfg.resolver.as_ref(),
172 repo_config,
173 name,
174 &hooks,
175 None,
176 );
177
178 if let Err(err) = hook_result {
179 result.status = UpdateStatus::Failed;
180 result.notes = err.to_string();
181 success = false;
182 results.push(result);
183
184 break;
185 }
186 }
187
188 if result.status == UpdateStatus::Failed {
189 success = false;
190 }
191
192 results.push(result);
193 }
194
195 (results, success)
196 }
197
198 #[allow(clippy::too_many_lines)]
203 fn update_repository(
204 &self,
205 repo_name: &str,
206 repo_config: &xianyun_core::config::latest::RepositoryConfig,
207 caproni_config: &CaproniConfig,
208 required_repos: &HashSet<String>,
209 ) -> UpdateResult {
210 let mut result = UpdateResult {
211 repo_name: repo_name.to_string(),
212 ..Default::default()
213 };
214
215 if let Some(filter) = &self.options.filter
217 && !filter(repo_name)
218 {
219 result.status = UpdateStatus::Skipped;
220 result.notes = "Filtered out".to_string();
221
222 return result;
223 }
224
225 let directory = caproni_config.resolve_path(&repo_config.directory);
227
228 let mut state = match self
230 .backend
231 .detect_state(&directory, &repo_config.repository)
232 {
233 Ok(state) => state,
234 Err(err) => {
235 result.status = UpdateStatus::Failed;
236 result.notes = format!("Failed to detect state: {err}");
237 result.error = Some(err);
238
239 return result;
240 }
241 };
242
243 state.current_branch.clone_into(&mut result.branch);
245 state.default_branch.clone_into(&mut result.default_branch);
246 result.commits_behind = state.commits_behind;
247 result.commits_ahead = state.commits_ahead;
248 state
249 .tracking_branch
250 .clone_into(&mut result.tracking_branch);
251
252 if self.should_refresh_tracking(repo_config, &state)
255 && let Some(res) = self.refresh_tracking_branch(&directory, &mut state, &mut result)
256 {
257 return res;
258 }
259
260 let is_required = required_repos.contains(repo_name);
262
263 if !state.exists && is_required && self.options.strategy != UpdateStrategy::VerifyOnly {
265 return self.clone_repository(
267 &repo_config.repository,
268 &directory,
269 &repo_config.ref_,
270 result,
271 );
272 }
273
274 if !state.exists && !is_required {
275 result.status = UpdateStatus::Skipped;
277 result.notes = "Not cloned and not required for edit_mode".to_string();
278
279 return result;
280 }
281
282 if !state.exists && self.options.strategy == UpdateStrategy::VerifyOnly {
283 result.status = UpdateStatus::Skipped;
285 result.notes = "Not cloned (verify-only mode)".to_string();
286
287 return result;
288 }
289
290 if self.options.strategy == UpdateStrategy::MinimalRequired && state.exists {
291 result.status = UpdateStatus::Skipped;
293 result.notes = "Already cloned".to_string();
294
295 return result;
296 }
297
298 if !state.is_git_repo {
299 result.status = UpdateStatus::Failed;
301 result.notes = "Not a Git repository".to_string();
302
303 return result;
304 }
305
306 if !state.remote_matches {
307 result.status = UpdateStatus::Warning;
309 result.notes = "Remote URL mismatch".to_string();
310
311 return result;
312 }
313
314 if state.has_uncommitted && !self.options.stash {
315 result.status = UpdateStatus::Dirty;
317 result.notes = "Uncommitted changes".to_string();
318
319 return result;
320 }
321
322 if state.has_uncommitted && self.options.stash {
323 return self.stash_and_update(&directory, &state, result);
325 }
326
327 if !repo_config.ref_.is_empty() {
328 return self.checkout_ref(&directory, &repo_config.ref_, false, result);
331 }
332
333 if state.is_detached {
334 result.status = UpdateStatus::Detached;
336 result.notes = "Not on any branch".to_string();
337
338 return result;
339 }
340
341 if state.commits_ahead > 0 && state.commits_behind > 0 {
342 result.status = UpdateStatus::Diverged;
344 result.notes = format!(
345 "{} behind, {} ahead of {}",
346 state.commits_behind, state.commits_ahead, state.tracking_branch
347 );
348
349 return result;
350 }
351
352 if state.commits_ahead > 0 {
353 result.status = UpdateStatus::Diverged;
355 result.notes = format!(
356 "{} commits ahead of {}",
357 state.commits_ahead, state.tracking_branch
358 );
359
360 return result;
361 }
362
363 if state.commits_behind > 0 {
364 return self.pull_repository(&directory, result);
366 }
367
368 result.status = UpdateStatus::Updated;
370 result.notes = "Already up-to-date".to_string();
371
372 result
373 }
374
375 fn should_refresh_tracking(
378 &self,
379 cfg: &xianyun_core::config::latest::RepositoryConfig,
380 state: &RepositoryState,
381 ) -> bool {
382 self.options.strategy == UpdateStrategy::FullUpdate
383 && cfg.ref_.is_empty()
384 && state.exists
385 && state.is_git_repo
386 && state.remote_matches
387 && !state.is_detached
388 && !state.tracking_branch.is_empty()
389 && (!state.has_uncommitted || self.options.stash)
390 }
391
392 fn refresh_tracking_branch(
396 &self,
397 directory: &str,
398 state: &mut RepositoryState,
399 result: &mut UpdateResult,
400 ) -> Option<UpdateResult> {
401 let (remote, branch) = split_tracking_branch(&state.tracking_branch);
402
403 if let Err(err) = self.backend.fetch(directory, &remote, &branch) {
404 result.status = UpdateStatus::Failed;
405 result.notes = format!("Fetch failed: {err}");
406 result.error = Some(err);
407
408 return Some(std::mem::take(result));
409 }
410
411 if let Ok((behind, ahead)) = self.backend.get_commit_counts(directory) {
412 state.commits_behind = behind;
413 state.commits_ahead = ahead;
414 result.commits_behind = behind;
415 result.commits_ahead = ahead;
416 }
417
418 None
419 }
420
421 fn pull_repository(&self, directory: &str, mut result: UpdateResult) -> UpdateResult {
423 if self.options.strategy == UpdateStrategy::VerifyOnly {
424 result.status = UpdateStatus::Updated;
425 result.notes = format!(
426 "Would pull {} commits (verify-only mode)",
427 result.commits_behind
428 );
429
430 return result;
431 }
432
433 if let Err(err) = self.backend.merge_ff_only(directory) {
436 result.status = UpdateStatus::Failed;
437 result.notes = format!("Pull failed: {err}");
438 result.error = Some(err);
439
440 return result;
441 }
442
443 result.status = UpdateStatus::Updated;
444 result.notes = format!("Pulled {} commits", result.commits_behind);
445 result.commits_behind = 0;
446
447 result
448 }
449
450 fn clone_repository(
453 &self,
454 repo_url: &str,
455 directory: &str,
456 ref_: &str,
457 mut result: UpdateResult,
458 ) -> UpdateResult {
459 if self.options.strategy == UpdateStrategy::VerifyOnly {
460 result.status = UpdateStatus::Updated;
461 if ref_.is_empty() {
462 result.notes = "Would clone (verify-only mode)".to_string();
463 } else {
464 result.notes = format!("Would clone and check out ref {ref_:?} (verify-only mode)");
465 }
466
467 return result;
468 }
469
470 if let Err(err) = self.backend.ensure_cloned(repo_url, directory, "on-demand") {
471 result.status = UpdateStatus::Failed;
472 result.notes = format!("Clone failed: {err}");
473 result.error = Some(err);
474
475 return result;
476 }
477
478 if !ref_.is_empty() {
479 return self.checkout_ref(directory, ref_, true, result);
481 }
482
483 result.status = UpdateStatus::Updated;
484 result.notes = "Cloned".to_string();
485
486 result
487 }
488
489 fn checkout_ref(
492 &self,
493 directory: &str,
494 ref_: &str,
495 skip_fetch: bool,
496 mut result: UpdateResult,
497 ) -> UpdateResult {
498 result.ref_ = ref_.to_string();
499
500 if self.options.strategy == UpdateStrategy::VerifyOnly {
501 result.status = UpdateStatus::Updated;
502 result.notes = format!("Would check out ref {ref_:?} (verify-only mode)");
503
504 return result;
505 }
506
507 if !skip_fetch {
508 let mut fetch_result = self.backend.fetch(directory, "origin", ref_);
509 if fetch_result.is_err() {
510 fetch_result = self.backend.fetch(directory, "origin", "");
513 }
514
515 if let Err(err) = fetch_result {
516 result.status = UpdateStatus::Failed;
517 result.notes = format!("Fetch failed: {err}");
518 result.error = Some(err);
519
520 return result;
521 }
522 }
523
524 if let Err(err) = self.backend.checkout(directory, ref_) {
525 result.status = UpdateStatus::Failed;
526 result.notes = format!("Checkout of ref {ref_:?} failed: {err}");
527 result.error = Some(err);
528
529 return result;
530 }
531
532 let detached = match self.backend.is_detached(directory) {
533 Ok(detached) => detached,
534 Err(err) => {
535 result.status = UpdateStatus::Failed;
536 result.notes = format!("Detecting HEAD state failed: {err}");
537 result.error = Some(err);
538
539 return result;
540 }
541 };
542
543 if detached {
544 result.status = UpdateStatus::Pinned;
546 result.notes = format!("Pinned at {ref_}");
547
548 return result;
549 }
550
551 if let Err(err) = self.backend.merge_ff_only(directory) {
553 result.status = UpdateStatus::Failed;
554 result.notes = format!("Fast-forward of branch {ref_:?} failed: {err}");
555 result.error = Some(err);
556
557 return result;
558 }
559
560 result.status = UpdateStatus::Updated;
561 result.notes = format!("Checked out and fast-forwarded branch {ref_}");
562
563 result
564 }
565
566 fn stash_and_update(
569 &self,
570 directory: &str,
571 _state: &RepositoryState,
572 mut result: UpdateResult,
573 ) -> UpdateResult {
574 if self.options.strategy == UpdateStrategy::VerifyOnly {
575 result.status = UpdateStatus::Updated;
576 result.notes = "Would stash and update (verify-only mode)".to_string();
577
578 return result;
579 }
580
581 if let Err(err) = self.backend.stash_push(directory, "caproni update --stash") {
582 result.status = UpdateStatus::Failed;
583 result.notes = format!("Stash failed: {err}");
584 result.error = Some(err);
585
586 return result;
587 }
588
589 self.pull_repository(directory, result)
590 }
591}
592
593fn split_tracking_branch(tracking_branch: &str) -> (String, String) {
597 match tracking_branch.split_once('/') {
598 Some((remote, branch)) => (remote.to_string(), branch.to_string()),
599 None => ("origin".to_string(), tracking_branch.to_string()),
600 }
601}
602
603pub fn format_table(results: &[UpdateResult], quiet: bool) -> String {
609 if results.is_empty() {
610 return "No repositories to update.\n".to_string();
611 }
612
613 let header = ["Repository", "Branch", "Status", "Behind", "Ahead", "Notes"];
614 let mut rows: Vec<[String; 6]> = Vec::with_capacity(results.len());
615
616 for result in results {
617 let branch = if result.branch.is_empty() {
618 match result.status {
619 UpdateStatus::Detached => "detached".to_string(),
620 UpdateStatus::Pinned => result.ref_.clone(),
621 _ => "-".to_string(),
622 }
623 } else {
624 result.branch.clone()
625 };
626
627 let behind = format_count(result.commits_behind, result.status);
628 let ahead = format_count(result.commits_ahead, result.status);
629
630 let notes = if !quiet && result.notes.is_empty() {
631 result
632 .error
633 .as_ref()
634 .map_or_else(|| result.notes.clone(), ToString::to_string)
635 } else {
636 result.notes.clone()
637 };
638
639 rows.push([
640 truncate(&result.repo_name, 25),
641 truncate(&branch, 20),
642 result.status.to_string(),
643 behind,
644 ahead,
645 truncate(¬es, 40),
646 ]);
647 }
648
649 let mut widths: [usize; 6] = header.map(str::len);
650 for row in &rows {
651 for (i, cell) in row.iter().enumerate() {
652 widths[i] = widths[i].max(cell.len());
653 }
654 }
655
656 let mut out = String::from("Repository Update Summary\n\n");
657 for (i, h) in header.iter().enumerate() {
658 out.push_str(&format!("{:<width$} ", h, width = widths[i]));
659 }
660 out.push('\n');
661
662 for row in &rows {
663 for (i, cell) in row.iter().enumerate() {
664 out.push_str(&format!("{:<width$} ", cell, width = widths[i]));
665 }
666 out.push('\n');
667 }
668
669 out
670}
671
672#[must_use]
674pub fn format_summary(results: &[UpdateResult]) -> String {
675 let total = results.len();
676 let mut updated = 0;
677 let mut pinned = 0;
678 let mut warnings = 0;
679 let mut failed = 0;
680 let mut skipped = 0;
681
682 for result in results {
683 match result.status {
684 UpdateStatus::Updated => updated += 1,
685 UpdateStatus::Pinned => pinned += 1,
686 UpdateStatus::Diverged
687 | UpdateStatus::Dirty
688 | UpdateStatus::Detached
689 | UpdateStatus::Warning => warnings += 1,
690 UpdateStatus::Failed => failed += 1,
691 UpdateStatus::Skipped => skipped += 1,
692 }
693 }
694
695 let mut b = String::new();
696 b.push('\n');
697 b.push_str("Summary:");
698 b.push('\n');
699 b.push_str(&format!(" {total} total repositories\n"));
700
701 if updated > 0 {
702 b.push_str(&format!(" {updated} updated successfully\n"));
703 }
704
705 if pinned > 0 {
706 b.push_str(&format!(" {pinned} pinned to a ref\n"));
707 }
708
709 if warnings > 0 {
710 b.push_str(&format!(" {warnings} need attention\n"));
711 }
712
713 if failed > 0 {
714 b.push_str(&format!(" {failed} failed\n"));
715 }
716
717 if skipped > 0 {
718 b.push_str(&format!(" {skipped} skipped\n"));
719 }
720
721 b
722}
723
724#[must_use]
727pub fn format_suggestions(results: &[UpdateResult]) -> String {
728 let needs_attention: Vec<&UpdateResult> = results
729 .iter()
730 .filter(|result| {
731 matches!(
732 result.status,
733 UpdateStatus::Diverged
734 | UpdateStatus::Dirty
735 | UpdateStatus::Detached
736 | UpdateStatus::Warning
737 )
738 })
739 .collect();
740
741 if needs_attention.is_empty() {
742 return String::new();
743 }
744
745 let mut b = String::new();
746 b.push('\n');
747 b.push_str("Repositories needing attention:");
748 b.push('\n');
749
750 for result in needs_attention {
751 let suggestion = get_suggestion(result);
752 b.push_str(&format!(
753 " \u{2022} {} ({}): {suggestion}\n",
754 result.repo_name, result.branch
755 ));
756 }
757
758 b
759}
760
761fn format_count(count: i64, status: UpdateStatus) -> String {
763 match status {
764 UpdateStatus::Skipped
765 | UpdateStatus::Detached
766 | UpdateStatus::Failed
767 | UpdateStatus::Pinned => "-".to_string(),
768 UpdateStatus::Updated
769 | UpdateStatus::Diverged
770 | UpdateStatus::Dirty
771 | UpdateStatus::Warning => count.to_string(),
772 }
773}
774
775fn get_suggestion(result: &UpdateResult) -> String {
778 match result.status {
779 UpdateStatus::Dirty => "Commit or stash changes, then run 'caproni update'".to_string(),
780 UpdateStatus::Detached => {
781 format!(
782 "Checkout a branch with 'git checkout {}'",
783 result.default_branch
784 )
785 }
786 UpdateStatus::Diverged => {
787 if result.commits_ahead > 0 && result.commits_behind > 0 {
788 "Run 'git pull --rebase' or merge and push".to_string()
789 } else if result.commits_ahead > 0 {
790 "Push local commits or rebase with remote".to_string()
791 } else {
792 "Pull latest changes".to_string()
793 }
794 }
795 UpdateStatus::Warning => {
796 if result.notes.contains("Remote URL") {
797 "Verify remote configuration with 'git remote -v'".to_string()
798 } else {
799 "Check repository configuration".to_string()
800 }
801 }
802 UpdateStatus::Updated
803 | UpdateStatus::Pinned
804 | UpdateStatus::Failed
805 | UpdateStatus::Skipped => "Manual intervention required".to_string(),
806 }
807}
808
809fn truncate(s: &str, max_len: usize) -> String {
812 if s.len() <= max_len {
813 return s.to_string();
814 }
815
816 if max_len <= 3 {
817 return s.chars().take(max_len).collect();
818 }
819
820 let mut truncated: String = s.chars().take(max_len - 3).collect();
821 truncated.push_str("...");
822
823 truncated
824}
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use std::sync::Arc;
829
830 use xianyun_backends::git::GitError;
831 use xianyun_core::config::global::GlobalConfig;
832 use xianyun_core::config::latest::{
833 CaproniConfig, DeployerConfig, LifecycleHookConfig, MirrorConfig, ReloaderConfig,
834 RepositoryConfig,
835 };
836 use xianyun_core::config::types::NetworkConfig;
837 use xianyun_core::orderedmap::OrderedMap;
838 use xianyun_core::variables::resolver::Resolver;
839
840 use crate::testutil::{MockCommandRunner, MockGitBackend};
841
842 const TEST_REPO_NAME: &str = "gitlab";
843
844 fn err_test_clone_failed() -> GitError {
849 GitError::Other("clone failed: network error".to_string())
850 }
851
852 fn err_test_exit_status1() -> GitError {
853 GitError::Other("exit status 1".to_string())
854 }
855
856 fn err_test_fetch_failed() -> GitError {
857 GitError::Other("fetch failed: network error".to_string())
858 }
859
860 fn err_test_pull_failed() -> GitError {
861 GitError::Other("pull failed".to_string())
862 }
863
864 #[derive(Default)]
869 struct TestResolver;
870
871 impl Resolver for TestResolver {
872 fn expand(&self, _name: &str) -> String {
873 String::new()
874 }
875
876 fn all(&self) -> std::collections::HashMap<String, String> {
877 std::collections::HashMap::new()
878 }
879
880 fn lookup(&self, _name: &str) -> Option<String> {
881 None
882 }
883 }
884
885 fn empty_caproni_config() -> CaproniConfig {
886 CaproniConfig {
887 config_dir: "/test".to_string(),
888 repositories: Some(OrderedMap::new()),
889 deployers: Some(OrderedMap::new()),
890 reloaders: Some(OrderedMap::new()),
891 ..Default::default()
892 }
893 }
894
895 fn new_test_container(mut caproni_config: CaproniConfig) -> Container {
899 if caproni_config.cluster.network.is_none() {
900 caproni_config.cluster.network = Some(NetworkConfig {
901 hostname: "test.example.com".to_string(),
902 ..Default::default()
903 });
904 }
905
906 Container {
907 global: GlobalConfig::default(),
908 caproni: caproni_config,
909 resolver: Box::new(TestResolver),
910 }
911 }
912
913 fn new_ref_test_config(ref_: &str, edit_mode: bool) -> CaproniConfig {
915 let mut cfg = empty_caproni_config();
916 cfg.repositories.as_mut().unwrap().set(
917 TEST_REPO_NAME.to_string(),
918 RepositoryConfig {
919 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
920 directory: "gitlab".to_string(),
921 ref_: ref_.to_string(),
922 edit_mode,
923 ..Default::default()
924 },
925 );
926
927 cfg
928 }
929
930 fn clean_existing_state() -> RepositoryState {
932 RepositoryState {
933 exists: true,
934 is_git_repo: true,
935 current_branch: "main".to_string(),
936 remote_matches: true,
937 ..Default::default()
938 }
939 }
940
941 fn no_ref_tracking_config() -> CaproniConfig {
943 let mut cfg = empty_caproni_config();
944 cfg.repositories.as_mut().unwrap().set(
945 "gitlab".to_string(),
946 RepositoryConfig {
947 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
948 directory: "gitlab".to_string(),
949 ..Default::default()
950 },
951 );
952
953 cfg
954 }
955
956 #[test]
959 fn determine_required_repositories_no_deployers_or_reloaders() {
960 let cfg = empty_caproni_config();
961 let result = determine_required_repositories(&cfg);
962 assert!(result.is_empty());
963 }
964
965 #[test]
966 fn determine_required_repositories_edit_mode_deployer() {
967 let mut cfg = empty_caproni_config();
968 cfg.deployers.as_mut().unwrap().set(
969 "gitlab-deployer".to_string(),
970 DeployerConfig {
971 edit_mode: true,
972 repository: Some(TEST_REPO_NAME.to_string()),
973 ..Default::default()
974 },
975 );
976
977 let result = determine_required_repositories(&cfg);
978 assert_eq!(
979 result,
980 std::collections::HashSet::from([TEST_REPO_NAME.to_string()])
981 );
982 }
983
984 #[test]
985 fn determine_required_repositories_non_edit_mode_deployer() {
986 let mut cfg = empty_caproni_config();
987 cfg.deployers.as_mut().unwrap().set(
988 "gitlab-deployer".to_string(),
989 DeployerConfig {
990 edit_mode: false,
991 repository: Some(TEST_REPO_NAME.to_string()),
992 ..Default::default()
993 },
994 );
995
996 let result = determine_required_repositories(&cfg);
997 assert!(result.is_empty());
998 }
999
1000 #[test]
1001 fn determine_required_repositories_edit_mode_reloader() {
1002 let mut cfg = empty_caproni_config();
1003 cfg.reloaders.as_mut().unwrap().set(
1004 "gitlab-reloader".to_string(),
1005 ReloaderConfig {
1006 edit_mode: true,
1007 repository: TEST_REPO_NAME.to_string(),
1008 ..Default::default()
1009 },
1010 );
1011
1012 let result = determine_required_repositories(&cfg);
1013 assert_eq!(
1014 result,
1015 std::collections::HashSet::from([TEST_REPO_NAME.to_string()])
1016 );
1017 }
1018
1019 #[test]
1020 fn determine_required_repositories_edit_mode_repository() {
1021 let mut cfg = empty_caproni_config();
1022 cfg.repositories.as_mut().unwrap().set(
1023 TEST_REPO_NAME.to_string(),
1024 RepositoryConfig {
1025 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1026 directory: "./gitlab/".to_string(),
1027 edit_mode: true,
1028 ..Default::default()
1029 },
1030 );
1031
1032 let result = determine_required_repositories(&cfg);
1033 assert_eq!(
1034 result,
1035 std::collections::HashSet::from([TEST_REPO_NAME.to_string()])
1036 );
1037 }
1038
1039 #[test]
1040 fn determine_required_repositories_multiple_edit_mode_items() {
1041 let mut cfg = empty_caproni_config();
1042 cfg.deployers.as_mut().unwrap().set(
1043 "gitlab-deployer".to_string(),
1044 DeployerConfig {
1045 edit_mode: true,
1046 repository: Some(TEST_REPO_NAME.to_string()),
1047 ..Default::default()
1048 },
1049 );
1050 cfg.deployers.as_mut().unwrap().set(
1051 "charts-deployer".to_string(),
1052 DeployerConfig {
1053 edit_mode: true,
1054 repository: Some("charts".to_string()),
1055 ..Default::default()
1056 },
1057 );
1058 cfg.reloaders.as_mut().unwrap().set(
1059 "gitlab-reloader".to_string(),
1060 ReloaderConfig {
1061 edit_mode: true,
1062 repository: "gitlab".to_string(),
1063 ..Default::default()
1064 },
1065 );
1066
1067 let result = determine_required_repositories(&cfg);
1068 assert_eq!(
1069 result,
1070 std::collections::HashSet::from(["gitlab".to_string(), "charts".to_string()])
1071 );
1072 }
1073
1074 #[test]
1077 fn update_repositories_strategy_full_update() {
1078 let mock_backend = Arc::new(MockGitBackend::new());
1079
1080 let mut caproni_config = empty_caproni_config();
1081 caproni_config.repositories.as_mut().unwrap().set(
1082 "gitlab".to_string(),
1083 RepositoryConfig {
1084 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1085 directory: "gitlab".to_string(),
1086 ..Default::default()
1087 },
1088 );
1089 caproni_config.deployers.as_mut().unwrap().set(
1090 "gitlab-deployer".to_string(),
1091 DeployerConfig {
1092 edit_mode: true,
1093 repository: Some(TEST_REPO_NAME.to_string()),
1094 ..Default::default()
1095 },
1096 );
1097
1098 mock_backend.expect_detect_state(
1099 "/test/gitlab",
1100 "https://gitlab.com/gitlab-org/gitlab.git",
1101 Ok(RepositoryState {
1102 exists: false,
1103 ..Default::default()
1104 }),
1105 );
1106 mock_backend.expect_ensure_cloned(
1107 "https://gitlab.com/gitlab-org/gitlab.git",
1108 "/test/gitlab",
1109 "on-demand",
1110 Ok(()),
1111 );
1112
1113 let updater = Updater::new(
1114 mock_backend.clone(),
1115 UpdateOptions {
1116 strategy: UpdateStrategy::FullUpdate,
1117 ..Default::default()
1118 },
1119 );
1120 let mock_executor = MockCommandRunner::new();
1121 let (results, success) =
1122 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1123
1124 assert!(success);
1125 assert_eq!(results.len(), 1);
1126 assert_eq!(results[0].repo_name, "gitlab");
1127 assert_eq!(results[0].status, UpdateStatus::Updated);
1128 assert_eq!(results[0].notes, "Cloned");
1129 }
1130
1131 #[test]
1134 fn update_repositories_existing_repo_pinned_sha() {
1135 let mock_backend = Arc::new(MockGitBackend::new());
1136 mock_backend.expect_detect_state(
1137 "/test/gitlab",
1138 "https://gitlab.com/gitlab-org/gitlab.git",
1139 Ok(clean_existing_state()),
1140 );
1141 mock_backend.expect_fetch("/test/gitlab", "origin", "abc123", Ok(()));
1142 mock_backend.expect_checkout("/test/gitlab", "abc123", Ok(()));
1143 mock_backend.expect_is_detached("/test/gitlab", Ok(true));
1144
1145 let updater = Updater::new(
1146 mock_backend.clone(),
1147 UpdateOptions {
1148 strategy: UpdateStrategy::FullUpdate,
1149 ..Default::default()
1150 },
1151 );
1152 let mock_executor = MockCommandRunner::new();
1153 let (results, success) = updater.update_repositories(
1154 &mock_executor,
1155 &new_test_container(new_ref_test_config("abc123", false)),
1156 );
1157
1158 assert!(success);
1159 assert_eq!(results.len(), 1);
1160 assert_eq!(results[0].status, UpdateStatus::Pinned);
1161 assert_eq!(results[0].notes, "Pinned at abc123");
1162 assert_eq!(results[0].ref_, "abc123");
1163 mock_backend.assert_not_called("MergeFFOnly");
1164 }
1165
1166 #[test]
1169 fn update_repositories_existing_repo_ref_branch() {
1170 let mock_backend = Arc::new(MockGitBackend::new());
1171 mock_backend.expect_detect_state(
1172 "/test/gitlab",
1173 "https://gitlab.com/gitlab-org/gitlab.git",
1174 Ok(clean_existing_state()),
1175 );
1176 mock_backend.expect_fetch("/test/gitlab", "origin", "feature-branch", Ok(()));
1177 mock_backend.expect_checkout("/test/gitlab", "feature-branch", Ok(()));
1178 mock_backend.expect_is_detached("/test/gitlab", Ok(false));
1179 mock_backend.expect_merge_ff_only("/test/gitlab", Ok(()));
1180
1181 let updater = Updater::new(
1182 mock_backend.clone(),
1183 UpdateOptions {
1184 strategy: UpdateStrategy::FullUpdate,
1185 ..Default::default()
1186 },
1187 );
1188 let mock_executor = MockCommandRunner::new();
1189 let (results, success) = updater.update_repositories(
1190 &mock_executor,
1191 &new_test_container(new_ref_test_config("feature-branch", false)),
1192 );
1193
1194 assert!(success);
1195 assert_eq!(results.len(), 1);
1196 assert_eq!(results[0].status, UpdateStatus::Updated);
1197 assert_eq!(
1198 results[0].notes,
1199 "Checked out and fast-forwarded branch feature-branch"
1200 );
1201 }
1202
1203 #[test]
1206 fn update_repositories_clone_with_ref() {
1207 let mock_backend = Arc::new(MockGitBackend::new());
1208 mock_backend.expect_detect_state(
1209 "/test/gitlab",
1210 "https://gitlab.com/gitlab-org/gitlab.git",
1211 Ok(RepositoryState {
1212 exists: false,
1213 ..Default::default()
1214 }),
1215 );
1216 mock_backend.expect_ensure_cloned(
1217 "https://gitlab.com/gitlab-org/gitlab.git",
1218 "/test/gitlab",
1219 "on-demand",
1220 Ok(()),
1221 );
1222 mock_backend.expect_checkout("/test/gitlab", "v1.2.3", Ok(()));
1223 mock_backend.expect_is_detached("/test/gitlab", Ok(true));
1224
1225 let updater = Updater::new(
1226 mock_backend.clone(),
1227 UpdateOptions {
1228 strategy: UpdateStrategy::FullUpdate,
1229 skip_prepare_hooks: true,
1230 ..Default::default()
1231 },
1232 );
1233 let mock_executor = MockCommandRunner::new();
1234 let (results, success) = updater.update_repositories(
1235 &mock_executor,
1236 &new_test_container(new_ref_test_config("v1.2.3", true)),
1237 );
1238
1239 assert!(success);
1240 assert_eq!(results.len(), 1);
1241 assert_eq!(results[0].status, UpdateStatus::Pinned);
1242 assert_eq!(results[0].notes, "Pinned at v1.2.3");
1243 }
1244
1245 #[test]
1248 fn update_repositories_ref_checkout_failed() {
1249 let mock_backend = Arc::new(MockGitBackend::new());
1250 mock_backend.expect_detect_state(
1251 "/test/gitlab",
1252 "https://gitlab.com/gitlab-org/gitlab.git",
1253 Ok(clean_existing_state()),
1254 );
1255 mock_backend.expect_fetch("/test/gitlab", "origin", "abc123", Ok(()));
1256 mock_backend.expect_checkout("/test/gitlab", "abc123", Err(err_test_exit_status1()));
1257
1258 let updater = Updater::new(
1259 mock_backend.clone(),
1260 UpdateOptions {
1261 strategy: UpdateStrategy::FullUpdate,
1262 ..Default::default()
1263 },
1264 );
1265 let mock_executor = MockCommandRunner::new();
1266 let (results, success) = updater.update_repositories(
1267 &mock_executor,
1268 &new_test_container(new_ref_test_config("abc123", false)),
1269 );
1270
1271 assert!(!success);
1272 assert_eq!(results.len(), 1);
1273 assert_eq!(results[0].status, UpdateStatus::Failed);
1274 assert!(results[0].notes.contains("Checkout of ref"));
1275 mock_backend.assert_not_called("IsDetached");
1276 }
1277
1278 #[test]
1281 fn update_repositories_ref_verify_only() {
1282 let mock_backend = Arc::new(MockGitBackend::new());
1283 mock_backend.expect_detect_state(
1284 "/test/gitlab",
1285 "https://gitlab.com/gitlab-org/gitlab.git",
1286 Ok(clean_existing_state()),
1287 );
1288
1289 let updater = Updater::new(
1290 mock_backend.clone(),
1291 UpdateOptions {
1292 strategy: UpdateStrategy::VerifyOnly,
1293 ..Default::default()
1294 },
1295 );
1296 let mock_executor = MockCommandRunner::new();
1297 let (results, success) = updater.update_repositories(
1298 &mock_executor,
1299 &new_test_container(new_ref_test_config("abc123", false)),
1300 );
1301
1302 assert!(success);
1303 assert_eq!(results.len(), 1);
1304 assert_eq!(results[0].status, UpdateStatus::Updated);
1305 assert!(results[0].notes.contains("Would check out ref"));
1306 mock_backend.assert_not_called("Checkout");
1307 }
1308
1309 #[test]
1312 fn update_repositories_strategy_minimal_required() {
1313 let mock_backend = Arc::new(MockGitBackend::new());
1314
1315 let mut caproni_config = empty_caproni_config();
1316 caproni_config.repositories.as_mut().unwrap().set(
1317 "gitlab".to_string(),
1318 RepositoryConfig {
1319 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1320 directory: "gitlab".to_string(),
1321 ..Default::default()
1322 },
1323 );
1324 caproni_config.deployers.as_mut().unwrap().set(
1325 "gitlab-deployer".to_string(),
1326 DeployerConfig {
1327 edit_mode: true,
1328 repository: Some(TEST_REPO_NAME.to_string()),
1329 ..Default::default()
1330 },
1331 );
1332
1333 mock_backend.expect_detect_state(
1334 "/test/gitlab",
1335 "https://gitlab.com/gitlab-org/gitlab.git",
1336 Ok(RepositoryState {
1337 exists: true,
1338 ..Default::default()
1339 }),
1340 );
1341
1342 let updater = Updater::new(
1343 mock_backend.clone(),
1344 UpdateOptions {
1345 strategy: UpdateStrategy::MinimalRequired,
1346 ..Default::default()
1347 },
1348 );
1349 let mock_executor = MockCommandRunner::new();
1350 let (results, success) =
1351 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1352
1353 assert!(success);
1354 assert_eq!(results.len(), 1);
1355 assert_eq!(results[0].repo_name, "gitlab");
1356 assert_eq!(results[0].status, UpdateStatus::Skipped);
1357 assert_eq!(results[0].notes, "Already cloned");
1358 }
1359
1360 #[test]
1363 fn update_repositories_strategy_verify_only() {
1364 let mock_backend = Arc::new(MockGitBackend::new());
1365
1366 let mut caproni_config = empty_caproni_config();
1367 caproni_config.repositories.as_mut().unwrap().set(
1368 "gitlab".to_string(),
1369 RepositoryConfig {
1370 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1371 directory: "gitlab".to_string(),
1372 ..Default::default()
1373 },
1374 );
1375 caproni_config.deployers.as_mut().unwrap().set(
1376 "gitlab-deployer".to_string(),
1377 DeployerConfig {
1378 edit_mode: true,
1379 repository: Some(TEST_REPO_NAME.to_string()),
1380 ..Default::default()
1381 },
1382 );
1383
1384 mock_backend.expect_detect_state(
1385 "/test/gitlab",
1386 "https://gitlab.com/gitlab-org/gitlab.git",
1387 Ok(RepositoryState {
1388 exists: false,
1389 ..Default::default()
1390 }),
1391 );
1392
1393 let updater = Updater::new(
1394 mock_backend.clone(),
1395 UpdateOptions {
1396 strategy: UpdateStrategy::VerifyOnly,
1397 ..Default::default()
1398 },
1399 );
1400 let mock_executor = MockCommandRunner::new();
1401 let (results, success) =
1402 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1403
1404 assert!(success);
1405 assert_eq!(results.len(), 1);
1406 assert_eq!(results[0].repo_name, "gitlab");
1407 assert_eq!(results[0].status, UpdateStatus::Skipped);
1408 assert_eq!(results[0].notes, "Not cloned (verify-only mode)");
1409 mock_backend.assert_not_called("EnsureCloned");
1410 }
1411
1412 #[test]
1415 fn update_repositories_non_required_repo_not_cloned() {
1416 let mock_backend = Arc::new(MockGitBackend::new());
1417
1418 let mut caproni_config = empty_caproni_config();
1419 caproni_config.repositories.as_mut().unwrap().set(
1420 "gitlab".to_string(),
1421 RepositoryConfig {
1422 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1423 directory: "gitlab".to_string(),
1424 ..Default::default()
1425 },
1426 );
1427
1428 mock_backend.expect_detect_state(
1429 "/test/gitlab",
1430 "https://gitlab.com/gitlab-org/gitlab.git",
1431 Ok(RepositoryState {
1432 exists: false,
1433 ..Default::default()
1434 }),
1435 );
1436
1437 let updater = Updater::new(
1438 mock_backend.clone(),
1439 UpdateOptions {
1440 strategy: UpdateStrategy::FullUpdate,
1441 ..Default::default()
1442 },
1443 );
1444 let mock_executor = MockCommandRunner::new();
1445 let (results, success) =
1446 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1447
1448 assert!(success);
1449 assert_eq!(results.len(), 1);
1450 assert_eq!(results[0].repo_name, "gitlab");
1451 assert_eq!(results[0].status, UpdateStatus::Skipped);
1452 assert_eq!(
1453 results[0].notes,
1454 "Not cloned and not required for edit_mode"
1455 );
1456 mock_backend.assert_not_called("EnsureCloned");
1457 }
1458
1459 #[test]
1462 fn update_repositories_existing_repo_needs_pull() {
1463 let mock_backend = Arc::new(MockGitBackend::new());
1464
1465 let mut caproni_config = empty_caproni_config();
1466 caproni_config.repositories.as_mut().unwrap().set(
1467 "gitlab".to_string(),
1468 RepositoryConfig {
1469 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1470 directory: "gitlab".to_string(),
1471 ..Default::default()
1472 },
1473 );
1474
1475 mock_backend.expect_detect_state(
1476 "/test/gitlab",
1477 "https://gitlab.com/gitlab-org/gitlab.git",
1478 Ok(RepositoryState {
1479 exists: true,
1480 is_git_repo: true,
1481 current_branch: "main".to_string(),
1482 tracking_branch: "origin/main".to_string(),
1483 commits_behind: 5,
1484 commits_ahead: 0,
1485 remote_matches: true,
1486 has_uncommitted: false,
1487 ..Default::default()
1488 }),
1489 );
1490 mock_backend.expect_fetch("/test/gitlab", "origin", "main", Ok(()));
1491 mock_backend.expect_get_commit_counts("/test/gitlab", Ok((5, 0)));
1492 mock_backend.expect_merge_ff_only("/test/gitlab", Ok(()));
1493
1494 let updater = Updater::new(
1495 mock_backend.clone(),
1496 UpdateOptions {
1497 strategy: UpdateStrategy::FullUpdate,
1498 ..Default::default()
1499 },
1500 );
1501 let mock_executor = MockCommandRunner::new();
1502 let (results, success) =
1503 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1504
1505 assert!(success);
1506 assert_eq!(results.len(), 1);
1507 assert_eq!(results[0].repo_name, "gitlab");
1508 assert_eq!(results[0].status, UpdateStatus::Updated);
1509 assert_eq!(results[0].notes, "Pulled 5 commits");
1510 }
1511
1512 #[test]
1519 fn update_repositories_no_ref_refresh_discovers_behind() {
1520 let mock_backend = Arc::new(MockGitBackend::new());
1521 mock_backend.expect_detect_state(
1522 "/test/gitlab",
1523 "https://gitlab.com/gitlab-org/gitlab.git",
1524 Ok(RepositoryState {
1525 exists: true,
1526 is_git_repo: true,
1527 current_branch: "main".to_string(),
1528 tracking_branch: "origin/main".to_string(),
1529 commits_behind: 0,
1530 remote_matches: true,
1531 ..Default::default()
1532 }),
1533 );
1534 mock_backend.expect_fetch("/test/gitlab", "origin", "main", Ok(()));
1535 mock_backend.expect_get_commit_counts("/test/gitlab", Ok((2, 0)));
1536 mock_backend.expect_merge_ff_only("/test/gitlab", Ok(()));
1537
1538 let updater = Updater::new(
1539 mock_backend.clone(),
1540 UpdateOptions {
1541 strategy: UpdateStrategy::FullUpdate,
1542 ..Default::default()
1543 },
1544 );
1545 let mock_executor = MockCommandRunner::new();
1546 let (results, success) = updater.update_repositories(
1547 &mock_executor,
1548 &new_test_container(no_ref_tracking_config()),
1549 );
1550
1551 assert!(success);
1552 assert_eq!(results.len(), 1);
1553 assert_eq!(results[0].status, UpdateStatus::Updated);
1554 assert_eq!(results[0].notes, "Pulled 2 commits");
1555 }
1556
1557 #[test]
1563 fn update_repositories_no_ref_refresh_up_to_date() {
1564 let mock_backend = Arc::new(MockGitBackend::new());
1565 mock_backend.expect_detect_state(
1566 "/test/gitlab",
1567 "https://gitlab.com/gitlab-org/gitlab.git",
1568 Ok(RepositoryState {
1569 exists: true,
1570 is_git_repo: true,
1571 current_branch: "main".to_string(),
1572 tracking_branch: "origin/main".to_string(),
1573 remote_matches: true,
1574 ..Default::default()
1575 }),
1576 );
1577 mock_backend.expect_fetch("/test/gitlab", "origin", "main", Ok(()));
1578 mock_backend.expect_get_commit_counts("/test/gitlab", Ok((0, 0)));
1579
1580 let updater = Updater::new(
1581 mock_backend.clone(),
1582 UpdateOptions {
1583 strategy: UpdateStrategy::FullUpdate,
1584 ..Default::default()
1585 },
1586 );
1587 let mock_executor = MockCommandRunner::new();
1588 let (results, success) = updater.update_repositories(
1589 &mock_executor,
1590 &new_test_container(no_ref_tracking_config()),
1591 );
1592
1593 assert!(success);
1594 assert_eq!(results.len(), 1);
1595 assert_eq!(results[0].status, UpdateStatus::Updated);
1596 assert_eq!(results[0].notes, "Already up-to-date");
1597 mock_backend.assert_not_called("MergeFFOnly");
1598 }
1599
1600 #[test]
1606 fn update_repositories_no_ref_no_upstream_skips_fetch() {
1607 let mock_backend = Arc::new(MockGitBackend::new());
1608 mock_backend.expect_detect_state(
1609 "/test/gitlab",
1610 "https://gitlab.com/gitlab-org/gitlab.git",
1611 Ok(RepositoryState {
1612 exists: true,
1613 is_git_repo: true,
1614 current_branch: "wip".to_string(),
1615 remote_matches: true,
1616 ..Default::default()
1617 }),
1618 );
1619
1620 let updater = Updater::new(
1621 mock_backend.clone(),
1622 UpdateOptions {
1623 strategy: UpdateStrategy::FullUpdate,
1624 ..Default::default()
1625 },
1626 );
1627 let mock_executor = MockCommandRunner::new();
1628 let (results, success) = updater.update_repositories(
1629 &mock_executor,
1630 &new_test_container(no_ref_tracking_config()),
1631 );
1632
1633 assert!(success);
1634 assert_eq!(results.len(), 1);
1635 assert_eq!(results[0].status, UpdateStatus::Updated);
1636 assert_eq!(results[0].notes, "Already up-to-date");
1637 mock_backend.assert_not_called("Fetch");
1638 }
1639
1640 #[test]
1646 fn update_repositories_no_ref_fetch_failed() {
1647 let mock_backend = Arc::new(MockGitBackend::new());
1648 mock_backend.expect_detect_state(
1649 "/test/gitlab",
1650 "https://gitlab.com/gitlab-org/gitlab.git",
1651 Ok(RepositoryState {
1652 exists: true,
1653 is_git_repo: true,
1654 current_branch: "main".to_string(),
1655 tracking_branch: "origin/main".to_string(),
1656 remote_matches: true,
1657 ..Default::default()
1658 }),
1659 );
1660 mock_backend.expect_fetch(
1661 "/test/gitlab",
1662 "origin",
1663 "main",
1664 Err(err_test_fetch_failed()),
1665 );
1666
1667 let updater = Updater::new(
1668 mock_backend.clone(),
1669 UpdateOptions {
1670 strategy: UpdateStrategy::FullUpdate,
1671 ..Default::default()
1672 },
1673 );
1674 let mock_executor = MockCommandRunner::new();
1675 let (results, success) = updater.update_repositories(
1676 &mock_executor,
1677 &new_test_container(no_ref_tracking_config()),
1678 );
1679
1680 assert!(!success);
1681 assert_eq!(results.len(), 1);
1682 assert_eq!(results[0].status, UpdateStatus::Failed);
1683 assert!(results[0].notes.contains("Fetch failed"));
1684 mock_backend.assert_not_called("MergeFFOnly");
1685 }
1686
1687 #[test]
1693 fn update_repositories_no_ref_refresh_non_origin_remote() {
1694 let mock_backend = Arc::new(MockGitBackend::new());
1695 mock_backend.expect_detect_state(
1696 "/test/gitlab",
1697 "https://gitlab.com/gitlab-org/gitlab.git",
1698 Ok(RepositoryState {
1699 exists: true,
1700 is_git_repo: true,
1701 current_branch: "main".to_string(),
1702 tracking_branch: "upstream/main".to_string(),
1703 remote_matches: true,
1704 ..Default::default()
1705 }),
1706 );
1707 mock_backend.expect_fetch("/test/gitlab", "upstream", "main", Ok(()));
1708 mock_backend.expect_get_commit_counts("/test/gitlab", Ok((1, 0)));
1709 mock_backend.expect_merge_ff_only("/test/gitlab", Ok(()));
1710
1711 let updater = Updater::new(
1712 mock_backend.clone(),
1713 UpdateOptions {
1714 strategy: UpdateStrategy::FullUpdate,
1715 ..Default::default()
1716 },
1717 );
1718 let mock_executor = MockCommandRunner::new();
1719 let (results, success) = updater.update_repositories(
1720 &mock_executor,
1721 &new_test_container(no_ref_tracking_config()),
1722 );
1723
1724 assert!(success);
1725 assert_eq!(results.len(), 1);
1726 assert_eq!(results[0].status, UpdateStatus::Updated);
1727 assert_eq!(results[0].notes, "Pulled 1 commits");
1728 }
1729
1730 #[test]
1736 fn update_repositories_ref_sha_fetch_fallback() {
1737 let mock_backend = Arc::new(MockGitBackend::new());
1738 mock_backend.expect_detect_state(
1739 "/test/gitlab",
1740 "https://gitlab.com/gitlab-org/gitlab.git",
1741 Ok(clean_existing_state()),
1742 );
1743 mock_backend.expect_fetch(
1744 "/test/gitlab",
1745 "origin",
1746 "abc123",
1747 Err(err_test_fetch_failed()),
1748 );
1749 mock_backend.expect_fetch("/test/gitlab", "origin", "", Ok(()));
1750 mock_backend.expect_checkout("/test/gitlab", "abc123", Ok(()));
1751 mock_backend.expect_is_detached("/test/gitlab", Ok(true));
1752
1753 let updater = Updater::new(
1754 mock_backend.clone(),
1755 UpdateOptions {
1756 strategy: UpdateStrategy::FullUpdate,
1757 ..Default::default()
1758 },
1759 );
1760 let mock_executor = MockCommandRunner::new();
1761 let (results, success) = updater.update_repositories(
1762 &mock_executor,
1763 &new_test_container(new_ref_test_config("abc123", false)),
1764 );
1765
1766 assert!(success);
1767 assert_eq!(results.len(), 1);
1768 assert_eq!(results[0].status, UpdateStatus::Pinned);
1769 assert_eq!(results[0].notes, "Pinned at abc123");
1770 }
1771
1772 #[test]
1775 fn update_repositories_dirty_repository() {
1776 let mock_backend = Arc::new(MockGitBackend::new());
1777
1778 let mut caproni_config = empty_caproni_config();
1779 caproni_config.repositories.as_mut().unwrap().set(
1780 "gitlab".to_string(),
1781 RepositoryConfig {
1782 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1783 directory: "gitlab".to_string(),
1784 ..Default::default()
1785 },
1786 );
1787
1788 mock_backend.expect_detect_state(
1789 "/test/gitlab",
1790 "https://gitlab.com/gitlab-org/gitlab.git",
1791 Ok(RepositoryState {
1792 exists: true,
1793 is_git_repo: true,
1794 current_branch: "main".to_string(),
1795 has_uncommitted: true,
1796 remote_matches: true,
1797 ..Default::default()
1798 }),
1799 );
1800
1801 let updater = Updater::new(
1802 mock_backend.clone(),
1803 UpdateOptions {
1804 strategy: UpdateStrategy::FullUpdate,
1805 stash: false,
1806 ..Default::default()
1807 },
1808 );
1809 let mock_executor = MockCommandRunner::new();
1810 let (results, success) =
1811 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1812
1813 assert!(success);
1814 assert_eq!(results.len(), 1);
1815 assert_eq!(results[0].repo_name, "gitlab");
1816 assert_eq!(results[0].status, UpdateStatus::Dirty);
1817 assert_eq!(results[0].notes, "Uncommitted changes");
1818 mock_backend.assert_not_called("MergeFFOnly");
1819 }
1820
1821 #[test]
1824 fn update_repositories_stash_and_update() {
1825 let mock_backend = Arc::new(MockGitBackend::new());
1826
1827 let mut caproni_config = empty_caproni_config();
1828 caproni_config.repositories.as_mut().unwrap().set(
1829 "gitlab".to_string(),
1830 RepositoryConfig {
1831 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1832 directory: "gitlab".to_string(),
1833 ..Default::default()
1834 },
1835 );
1836
1837 mock_backend.expect_detect_state(
1838 "/test/gitlab",
1839 "https://gitlab.com/gitlab-org/gitlab.git",
1840 Ok(RepositoryState {
1841 exists: true,
1842 is_git_repo: true,
1843 current_branch: "main".to_string(),
1844 tracking_branch: "origin/main".to_string(),
1845 commits_behind: 3,
1846 has_uncommitted: true,
1847 remote_matches: true,
1848 ..Default::default()
1849 }),
1850 );
1851 mock_backend.expect_fetch("/test/gitlab", "origin", "main", Ok(()));
1852 mock_backend.expect_get_commit_counts("/test/gitlab", Ok((3, 0)));
1853 mock_backend.expect_stash_push("/test/gitlab", "caproni update --stash", Ok(()));
1854 mock_backend.expect_merge_ff_only("/test/gitlab", Ok(()));
1855
1856 let updater = Updater::new(
1857 mock_backend.clone(),
1858 UpdateOptions {
1859 strategy: UpdateStrategy::FullUpdate,
1860 stash: true,
1861 ..Default::default()
1862 },
1863 );
1864 let mock_executor = MockCommandRunner::new();
1865 let (results, success) =
1866 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1867
1868 assert!(success);
1869 assert_eq!(results.len(), 1);
1870 assert_eq!(results[0].repo_name, "gitlab");
1871 assert_eq!(results[0].status, UpdateStatus::Updated);
1872 assert_eq!(results[0].notes, "Pulled 3 commits");
1873 }
1874
1875 #[test]
1878 fn update_repositories_diverged_repository() {
1879 let mock_backend = Arc::new(MockGitBackend::new());
1880
1881 let mut caproni_config = empty_caproni_config();
1882 caproni_config.repositories.as_mut().unwrap().set(
1883 "gitlab".to_string(),
1884 RepositoryConfig {
1885 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1886 directory: "gitlab".to_string(),
1887 ..Default::default()
1888 },
1889 );
1890
1891 mock_backend.expect_detect_state(
1892 "/test/gitlab",
1893 "https://gitlab.com/gitlab-org/gitlab.git",
1894 Ok(RepositoryState {
1895 exists: true,
1896 is_git_repo: true,
1897 current_branch: "main".to_string(),
1898 tracking_branch: "origin/main".to_string(),
1899 commits_behind: 5,
1900 commits_ahead: 3,
1901 remote_matches: true,
1902 ..Default::default()
1903 }),
1904 );
1905 mock_backend.expect_fetch("/test/gitlab", "origin", "main", Ok(()));
1906 mock_backend.expect_get_commit_counts("/test/gitlab", Ok((5, 3)));
1907
1908 let updater = Updater::new(
1909 mock_backend.clone(),
1910 UpdateOptions {
1911 strategy: UpdateStrategy::FullUpdate,
1912 ..Default::default()
1913 },
1914 );
1915 let mock_executor = MockCommandRunner::new();
1916 let (results, success) =
1917 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1918
1919 assert!(success);
1920 assert_eq!(results.len(), 1);
1921 assert_eq!(results[0].repo_name, "gitlab");
1922 assert_eq!(results[0].status, UpdateStatus::Diverged);
1923 assert_eq!(results[0].notes, "5 behind, 3 ahead of origin/main");
1924 mock_backend.assert_not_called("MergeFFOnly");
1925 }
1926
1927 #[test]
1930 fn update_repositories_detached_head() {
1931 let mock_backend = Arc::new(MockGitBackend::new());
1932
1933 let mut caproni_config = empty_caproni_config();
1934 caproni_config.repositories.as_mut().unwrap().set(
1935 "gitlab".to_string(),
1936 RepositoryConfig {
1937 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1938 directory: "gitlab".to_string(),
1939 ..Default::default()
1940 },
1941 );
1942
1943 mock_backend.expect_detect_state(
1944 "/test/gitlab",
1945 "https://gitlab.com/gitlab-org/gitlab.git",
1946 Ok(RepositoryState {
1947 exists: true,
1948 is_git_repo: true,
1949 is_detached: true,
1950 remote_matches: true,
1951 ..Default::default()
1952 }),
1953 );
1954
1955 let updater = Updater::new(
1956 mock_backend.clone(),
1957 UpdateOptions {
1958 strategy: UpdateStrategy::FullUpdate,
1959 ..Default::default()
1960 },
1961 );
1962 let mock_executor = MockCommandRunner::new();
1963 let (results, success) =
1964 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
1965
1966 assert!(success);
1967 assert_eq!(results.len(), 1);
1968 assert_eq!(results[0].repo_name, "gitlab");
1969 assert_eq!(results[0].status, UpdateStatus::Detached);
1970 assert_eq!(results[0].notes, "Not on any branch");
1971 }
1972
1973 #[test]
1976 fn update_repositories_filter_predicate() {
1977 let mock_backend = Arc::new(MockGitBackend::new());
1978
1979 let mut caproni_config = empty_caproni_config();
1980 caproni_config.repositories.as_mut().unwrap().set(
1981 "gitlab".to_string(),
1982 RepositoryConfig {
1983 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
1984 directory: "gitlab".to_string(),
1985 ..Default::default()
1986 },
1987 );
1988 caproni_config.repositories.as_mut().unwrap().set(
1989 "charts".to_string(),
1990 RepositoryConfig {
1991 repository: "https://gitlab.com/gitlab-org/charts/gitlab.git".to_string(),
1992 directory: "charts".to_string(),
1993 ..Default::default()
1994 },
1995 );
1996
1997 mock_backend.expect_detect_state(
1998 "/test/gitlab",
1999 "https://gitlab.com/gitlab-org/gitlab.git",
2000 Ok(RepositoryState {
2001 exists: true,
2002 is_git_repo: true,
2003 current_branch: "main".to_string(),
2004 remote_matches: true,
2005 ..Default::default()
2006 }),
2007 );
2008
2009 let filter: RepoFilterFn = Box::new(|repo_name: &str| repo_name == "gitlab");
2010 let updater = Updater::new(
2011 mock_backend.clone(),
2012 UpdateOptions {
2013 strategy: UpdateStrategy::FullUpdate,
2014 filter: Some(filter),
2015 ..Default::default()
2016 },
2017 );
2018 let mock_executor = MockCommandRunner::new();
2019 let (results, success) =
2020 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
2021
2022 assert!(success);
2023 assert_eq!(results.len(), 2);
2024
2025 assert_eq!(results[0].repo_name, "gitlab");
2026 assert_eq!(results[0].status, UpdateStatus::Updated);
2027 assert_eq!(results[0].notes, "Already up-to-date");
2028
2029 assert_eq!(results[1].repo_name, "charts");
2030 assert_eq!(results[1].status, UpdateStatus::Skipped);
2031 assert_eq!(results[1].notes, "Filtered out");
2032
2033 }
2041
2042 #[test]
2045 fn update_repositories_failure_returns_failed_status() {
2046 let mock_backend = Arc::new(MockGitBackend::new());
2047
2048 let mut caproni_config = empty_caproni_config();
2049 caproni_config.repositories.as_mut().unwrap().set(
2050 "gitlab".to_string(),
2051 RepositoryConfig {
2052 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
2053 directory: "gitlab".to_string(),
2054 ..Default::default()
2055 },
2056 );
2057 caproni_config.deployers.as_mut().unwrap().set(
2058 "gitlab-deployer".to_string(),
2059 DeployerConfig {
2060 edit_mode: true,
2061 repository: Some(TEST_REPO_NAME.to_string()),
2062 ..Default::default()
2063 },
2064 );
2065
2066 mock_backend.expect_detect_state(
2067 "/test/gitlab",
2068 "https://gitlab.com/gitlab-org/gitlab.git",
2069 Ok(RepositoryState {
2070 exists: false,
2071 ..Default::default()
2072 }),
2073 );
2074 mock_backend.expect_ensure_cloned(
2075 "https://gitlab.com/gitlab-org/gitlab.git",
2076 "/test/gitlab",
2077 "on-demand",
2078 Err(err_test_clone_failed()),
2079 );
2080
2081 let updater = Updater::new(
2082 mock_backend.clone(),
2083 UpdateOptions {
2084 strategy: UpdateStrategy::FullUpdate,
2085 ..Default::default()
2086 },
2087 );
2088 let mock_executor = MockCommandRunner::new();
2089 let (results, success) =
2090 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
2091
2092 assert!(!success);
2093 assert_eq!(results.len(), 1);
2094 assert_eq!(results[0].repo_name, "gitlab");
2095 assert_eq!(results[0].status, UpdateStatus::Failed);
2096 assert_eq!(
2097 results[0].notes,
2098 "Clone failed: clone failed: network error"
2099 );
2100 assert_eq!(
2101 results[0].error.as_ref().map(ToString::to_string),
2102 Some(err_test_clone_failed().to_string())
2103 );
2104 }
2105
2106 #[test]
2109 fn update_repositories_multiple_repos_preserves_order() {
2110 let mock_backend = Arc::new(MockGitBackend::new());
2111
2112 let mut caproni_config = empty_caproni_config();
2113 let repos = ["first", "second", "third"];
2114 for name in repos {
2115 caproni_config.repositories.as_mut().unwrap().set(
2116 name.to_string(),
2117 RepositoryConfig {
2118 repository: format!("https://example.com/{name}.git"),
2119 directory: name.to_string(),
2120 ..Default::default()
2121 },
2122 );
2123
2124 mock_backend.expect_detect_state(
2125 &format!("/test/{name}"),
2126 &format!("https://example.com/{name}.git"),
2127 Ok(RepositoryState {
2128 exists: true,
2129 is_git_repo: true,
2130 current_branch: "main".to_string(),
2131 remote_matches: true,
2132 ..Default::default()
2133 }),
2134 );
2135 }
2136
2137 let updater = Updater::new(
2138 mock_backend.clone(),
2139 UpdateOptions {
2140 strategy: UpdateStrategy::FullUpdate,
2141 ..Default::default()
2142 },
2143 );
2144 let mock_executor = MockCommandRunner::new();
2145 let (results, success) =
2146 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
2147
2148 assert!(success);
2149 assert_eq!(results.len(), 3);
2150
2151 for (i, expected_name) in repos.iter().enumerate() {
2152 assert_eq!(results[i].repo_name, *expected_name);
2153 }
2154 }
2155
2156 #[test]
2159 fn update_repositories_remote_url_mismatch() {
2160 let mock_backend = Arc::new(MockGitBackend::new());
2161
2162 let mut caproni_config = empty_caproni_config();
2163 caproni_config.repositories.as_mut().unwrap().set(
2164 "gitlab".to_string(),
2165 RepositoryConfig {
2166 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
2167 directory: "gitlab".to_string(),
2168 ..Default::default()
2169 },
2170 );
2171
2172 mock_backend.expect_detect_state(
2173 "/test/gitlab",
2174 "https://gitlab.com/gitlab-org/gitlab.git",
2175 Ok(RepositoryState {
2176 exists: true,
2177 is_git_repo: true,
2178 remote_matches: false,
2179 remote_url: "https://github.com/other/repo.git".to_string(),
2180 ..Default::default()
2181 }),
2182 );
2183
2184 let updater = Updater::new(
2185 mock_backend.clone(),
2186 UpdateOptions {
2187 strategy: UpdateStrategy::FullUpdate,
2188 ..Default::default()
2189 },
2190 );
2191 let mock_executor = MockCommandRunner::new();
2192 let (results, success) =
2193 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
2194
2195 assert!(success);
2196 assert_eq!(results.len(), 1);
2197 assert_eq!(results[0].repo_name, "gitlab");
2198 assert_eq!(results[0].status, UpdateStatus::Warning);
2199 assert_eq!(results[0].notes, "Remote URL mismatch");
2200 }
2201
2202 #[test]
2205 fn update_repositories_not_git_repository() {
2206 let mock_backend = Arc::new(MockGitBackend::new());
2207
2208 let mut caproni_config = empty_caproni_config();
2209 caproni_config.repositories.as_mut().unwrap().set(
2210 "gitlab".to_string(),
2211 RepositoryConfig {
2212 repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
2213 directory: "gitlab".to_string(),
2214 ..Default::default()
2215 },
2216 );
2217
2218 mock_backend.expect_detect_state(
2219 "/test/gitlab",
2220 "https://gitlab.com/gitlab-org/gitlab.git",
2221 Ok(RepositoryState {
2222 exists: true,
2223 is_git_repo: false,
2224 ..Default::default()
2225 }),
2226 );
2227
2228 let updater = Updater::new(
2229 mock_backend.clone(),
2230 UpdateOptions {
2231 strategy: UpdateStrategy::FullUpdate,
2232 ..Default::default()
2233 },
2234 );
2235 let mock_executor = MockCommandRunner::new();
2236 let (results, success) =
2237 updater.update_repositories(&mock_executor, &new_test_container(caproni_config));
2238
2239 assert!(!success); assert_eq!(results.len(), 1);
2241 assert_eq!(results[0].repo_name, "gitlab");
2242 assert_eq!(results[0].status, UpdateStatus::Failed);
2243 assert_eq!(results[0].notes, "Not a Git repository");
2244 }
2245
2246 const HOOKS_REPO_URL: &str = "https://gitlab.com/gitlab-org/gitlab.git";
2255
2256 fn up_to_date_state() -> RepositoryState {
2257 RepositoryState {
2258 exists: true,
2259 is_git_repo: true,
2260 current_branch: "main".to_string(),
2261 remote_matches: true,
2262 ..Default::default()
2263 }
2264 }
2265
2266 fn dirty_state() -> RepositoryState {
2267 RepositoryState {
2268 exists: true,
2269 is_git_repo: true,
2270 current_branch: "main".to_string(),
2271 remote_matches: true,
2272 has_uncommitted: true,
2273 ..Default::default()
2274 }
2275 }
2276
2277 fn hooks_test_config(hooks: Vec<LifecycleHookConfig>) -> CaproniConfig {
2280 let mut cfg = empty_caproni_config();
2281 cfg.repositories.as_mut().unwrap().set(
2282 "gitlab".to_string(),
2283 RepositoryConfig {
2284 repository: HOOKS_REPO_URL.to_string(),
2285 directory: "gitlab".to_string(),
2286 ..Default::default()
2287 },
2288 );
2289 cfg.reloaders.as_mut().unwrap().set(
2290 "gitlab-reloader".to_string(),
2291 ReloaderConfig {
2292 repository: "gitlab".to_string(),
2293 mirror: Some(MirrorConfig {
2294 lifecycle_hooks: hooks,
2295 ..Default::default()
2296 }),
2297 ..Default::default()
2298 },
2299 );
2300
2301 cfg
2302 }
2303
2304 #[test]
2305 fn update_repositories_hooks_hook_runs_successfully() {
2306 let mock_backend = Arc::new(MockGitBackend::new());
2307 mock_backend.expect_detect_state("/test/gitlab", HOOKS_REPO_URL, Ok(up_to_date_state()));
2308
2309 let mock_executor = MockCommandRunner::new();
2310 mock_executor.expect_run_in_repository("/test/gitlab", "make", &["prepare"], Ok(()));
2311
2312 let hooks = vec![LifecycleHookConfig {
2313 hook_type: "edit_mode_prepare".to_string(),
2314 script: "make prepare".to_string(),
2315 ..Default::default()
2316 }];
2317
2318 let updater = Updater::new(
2319 mock_backend.clone(),
2320 UpdateOptions {
2321 strategy: UpdateStrategy::FullUpdate,
2322 ..Default::default()
2323 },
2324 );
2325 let (results, success) = updater.update_repositories(
2326 &mock_executor,
2327 &new_test_container(hooks_test_config(hooks)),
2328 );
2329
2330 assert!(success);
2331 assert_eq!(results.len(), 1);
2332 assert_eq!(results[0].status, UpdateStatus::Updated);
2333 }
2334
2335 #[test]
2336 fn update_repositories_hooks_hook_script_parsing_fails() {
2337 let mock_backend = Arc::new(MockGitBackend::new());
2338 mock_backend.expect_detect_state("/test/gitlab", HOOKS_REPO_URL, Ok(up_to_date_state()));
2339
2340 let mock_executor = MockCommandRunner::new();
2341
2342 let hooks = vec![LifecycleHookConfig {
2343 hook_type: "edit_mode_prepare".to_string(),
2344 script: "unterminated 'single quote".to_string(),
2345 ..Default::default()
2346 }];
2347
2348 let updater = Updater::new(
2349 mock_backend.clone(),
2350 UpdateOptions {
2351 strategy: UpdateStrategy::FullUpdate,
2352 ..Default::default()
2353 },
2354 );
2355 let (results, success) = updater.update_repositories(
2356 &mock_executor,
2357 &new_test_container(hooks_test_config(hooks)),
2358 );
2359
2360 assert!(!success);
2361 assert_eq!(results.len(), 1);
2362 assert_eq!(results[0].status, UpdateStatus::Failed);
2363 assert!(results[0].notes.contains("parsing hook"));
2364 }
2365
2366 #[test]
2367 fn update_repositories_hooks_hook_execution_fails() {
2368 let mock_backend = Arc::new(MockGitBackend::new());
2369 mock_backend.expect_detect_state("/test/gitlab", HOOKS_REPO_URL, Ok(up_to_date_state()));
2370
2371 let mock_executor = MockCommandRunner::new();
2372 mock_executor.expect_run_in_repository(
2373 "/test/gitlab",
2374 "make",
2375 &["prepare"],
2376 Err(err_test_exit_status1_exec()),
2377 );
2378
2379 let hooks = vec![LifecycleHookConfig {
2380 hook_type: "edit_mode_prepare".to_string(),
2381 script: "make prepare".to_string(),
2382 ..Default::default()
2383 }];
2384
2385 let updater = Updater::new(
2386 mock_backend.clone(),
2387 UpdateOptions {
2388 strategy: UpdateStrategy::FullUpdate,
2389 ..Default::default()
2390 },
2391 );
2392 let (results, success) = updater.update_repositories(
2393 &mock_executor,
2394 &new_test_container(hooks_test_config(hooks)),
2395 );
2396
2397 assert!(!success);
2398 assert_eq!(results.len(), 1);
2399 assert_eq!(results[0].status, UpdateStatus::Failed);
2400 assert!(
2401 results[0]
2402 .notes
2403 .contains("running [make prepare] in repo gitlab")
2404 );
2405 }
2406
2407 #[test]
2408 fn update_repositories_hooks_non_prepare_hooks_are_ignored() {
2409 let mock_backend = Arc::new(MockGitBackend::new());
2410 mock_backend.expect_detect_state("/test/gitlab", HOOKS_REPO_URL, Ok(up_to_date_state()));
2411
2412 let mock_executor = MockCommandRunner::new();
2413 mock_executor.expect_run_in_repository("/test/gitlab", "make", &["prepare"], Ok(()));
2414
2415 let hooks = vec![
2416 LifecycleHookConfig {
2417 hook_type: "edit_mode_start".to_string(),
2418 script: "make start".to_string(),
2419 ..Default::default()
2420 },
2421 LifecycleHookConfig {
2422 hook_type: "edit_mode_prepare".to_string(),
2423 script: "make prepare".to_string(),
2424 ..Default::default()
2425 },
2426 ];
2427
2428 let updater = Updater::new(
2429 mock_backend.clone(),
2430 UpdateOptions {
2431 strategy: UpdateStrategy::FullUpdate,
2432 ..Default::default()
2433 },
2434 );
2435 let (results, success) = updater.update_repositories(
2436 &mock_executor,
2437 &new_test_container(hooks_test_config(hooks)),
2438 );
2439
2440 assert!(success);
2441 assert_eq!(results.len(), 1);
2442 assert_eq!(results[0].status, UpdateStatus::Updated);
2443 }
2444
2445 #[test]
2446 fn update_repositories_hooks_multiple_hooks_second_fails() {
2447 let mock_backend = Arc::new(MockGitBackend::new());
2448 mock_backend.expect_detect_state("/test/gitlab", HOOKS_REPO_URL, Ok(up_to_date_state()));
2449
2450 let mock_executor = MockCommandRunner::new();
2451 mock_executor.expect_run_in_repository("/test/gitlab", "make", &["first"], Ok(()));
2452 mock_executor.expect_run_in_repository(
2453 "/test/gitlab",
2454 "make",
2455 &["second"],
2456 Err(err_test_exit_status1_exec()),
2457 );
2458
2459 let hooks = vec![
2460 LifecycleHookConfig {
2461 hook_type: "edit_mode_prepare".to_string(),
2462 script: "make first".to_string(),
2463 ..Default::default()
2464 },
2465 LifecycleHookConfig {
2466 hook_type: "edit_mode_prepare".to_string(),
2467 script: "make second".to_string(),
2468 ..Default::default()
2469 },
2470 ];
2471
2472 let updater = Updater::new(
2473 mock_backend.clone(),
2474 UpdateOptions {
2475 strategy: UpdateStrategy::FullUpdate,
2476 ..Default::default()
2477 },
2478 );
2479 let (results, success) = updater.update_repositories(
2480 &mock_executor,
2481 &new_test_container(hooks_test_config(hooks)),
2482 );
2483
2484 assert!(!success);
2485 assert_eq!(results.len(), 1);
2486 assert_eq!(results[0].status, UpdateStatus::Failed);
2487 assert!(results[0].notes.contains("make second"));
2488 }
2489
2490 #[test]
2491 fn update_repositories_hooks_not_run_when_repo_is_dirty() {
2492 let mock_backend = Arc::new(MockGitBackend::new());
2493 mock_backend.expect_detect_state("/test/gitlab", HOOKS_REPO_URL, Ok(dirty_state()));
2494
2495 let mock_executor = MockCommandRunner::new();
2496
2497 let hooks = vec![LifecycleHookConfig {
2498 hook_type: "edit_mode_prepare".to_string(),
2499 script: "make prepare".to_string(),
2500 ..Default::default()
2501 }];
2502
2503 let updater = Updater::new(
2504 mock_backend.clone(),
2505 UpdateOptions {
2506 strategy: UpdateStrategy::FullUpdate,
2507 ..Default::default()
2508 },
2509 );
2510 let (results, success) = updater.update_repositories(
2511 &mock_executor,
2512 &new_test_container(hooks_test_config(hooks)),
2513 );
2514
2515 assert!(success);
2516 assert_eq!(results.len(), 1);
2517 assert_eq!(results[0].status, UpdateStatus::Dirty);
2518 }
2519
2520 #[test]
2521 fn update_repositories_hooks_not_run_when_pull_fails() {
2522 let mock_backend = Arc::new(MockGitBackend::new());
2523 mock_backend.expect_detect_state(
2524 "/test/gitlab",
2525 HOOKS_REPO_URL,
2526 Ok(RepositoryState {
2527 exists: true,
2528 is_git_repo: true,
2529 current_branch: "main".to_string(),
2530 remote_matches: true,
2531 commits_behind: 1,
2532 ..Default::default()
2533 }),
2534 );
2535 mock_backend.expect_merge_ff_only("/test/gitlab", Err(err_test_pull_failed()));
2536
2537 let mock_executor = MockCommandRunner::new();
2538
2539 let hooks = vec![LifecycleHookConfig {
2540 hook_type: "edit_mode_prepare".to_string(),
2541 script: "make prepare".to_string(),
2542 ..Default::default()
2543 }];
2544
2545 let updater = Updater::new(
2546 mock_backend.clone(),
2547 UpdateOptions {
2548 strategy: UpdateStrategy::FullUpdate,
2549 ..Default::default()
2550 },
2551 );
2552 let (results, success) = updater.update_repositories(
2553 &mock_executor,
2554 &new_test_container(hooks_test_config(hooks)),
2555 );
2556
2557 assert!(!success);
2558 assert_eq!(results.len(), 1);
2559 assert_eq!(results[0].status, UpdateStatus::Failed);
2560 assert!(results[0].notes.contains("Pull failed"));
2561 }
2562
2563 fn err_test_exit_status1_exec() -> xianyun_executor::exec::ExecError {
2567 xianyun_executor::exec::ExecError::CommandFailed(1)
2568 }
2569}