Skip to main content

xianyun_update/
updater.rs

1//! Repository update orchestration and result/output formatting.
2//!
3//! Ported from `internal/update/{updater,result,output}.go`, including
4//! every test in `updater_test.go`. See `PORT_NOTES.md`.
5
6use 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/// Ported from `UpdateStatus`. Go's zero value (`iota` 0) is `StatusUpdated`;
15/// [`Default`] mirrors that so a zero-initialized [`UpdateResult`] matches
16/// Go's zero-valued `UpdateResult`.
17#[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/// Ported from `UpdateResult`.
48#[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/// Ported from `UpdateStrategy`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum UpdateStrategy {
65    #[default]
66    FullUpdate,
67    MinimalRequired,
68    VerifyOnly,
69}
70
71/// Ported from `RepoFilterFunc`.
72pub type RepoFilterFn = Box<dyn Fn(&str) -> bool + Send + Sync>;
73
74/// Ported from `UpdateOptions`.
75#[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/// Ported from `DetermineRequiredRepositories`. Returns the set of
85/// repository names that are required based on `edit_mode` deployers,
86/// reloaders, and repositories in the configuration.
87#[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
120/// Ported from `Updater`.
121pub struct Updater {
122    backend: Arc<dyn GitBackend + Send + Sync>,
123    options: UpdateOptions,
124}
125
126impl Updater {
127    /// Ported from `NewUpdater`.
128    pub fn new(backend: Arc<dyn GitBackend + Send + Sync>, options: UpdateOptions) -> Self {
129        Self { backend, options }
130    }
131
132    /// Ported from `(*Updater).UpdateRepositories`. Updates multiple
133    /// repositories based on configuration. Returns results for all
134    /// repositories and a success flag. The success flag is `false` if
135    /// any repository has [`UpdateStatus::Failed`].
136    ///
137    /// This also invokes the [`crate::lifecyclehooks::HookType::EditModePrepare`]
138    /// hooks but without the `KUBECONFIG` because the cluster isn't
139    /// guaranteed to be available yet.
140    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        // Calculate required repos once (for FullUpdate and MinimalRequired).
149        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    /// Ported from `(*Updater).updateRepository`. Performs update for a
199    /// single repository with pre-calculated required repos.
200    // Ported from the Go original's own `//nolint:gocyclo,cyclop,funlen`:
201    // acceptable complexity for comprehensive update decision logic.
202    #[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        // Apply filter predicate.
216        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        // Resolve directory path relative to config file.
226        let directory = caproni_config.resolve_path(&repo_config.directory);
227
228        // Detect repository state.
229        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        // Populate result with state information.
244        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        // No pinned ref: refresh the current branch's upstream so
253        // behind/ahead below is accurate.
254        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        // Check if this repo is required for edit_mode.
261        let is_required = required_repos.contains(repo_name);
262
263        // Strategy-based decision logic.
264        if !state.exists && is_required && self.options.strategy != UpdateStrategy::VerifyOnly {
265            // Clone missing repos that are required (FullUpdate or MinimalRequired).
266            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            // Skip repos that aren't required for edit_mode.
276            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            // VerifyOnly: don't clone, just report.
284            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            // MinimalRequired: if repo exists, skip all other checks.
292            result.status = UpdateStatus::Skipped;
293            result.notes = "Already cloned".to_string();
294
295            return result;
296        }
297
298        if !state.is_git_repo {
299            // Directory exists but is not a git repository.
300            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            // Remote URL doesn't match configuration.
308            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            // Uncommitted changes prevent update.
316            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            // Stash uncommitted changes if --stash flag is set.
324            return self.stash_and_update(&directory, &state, result);
325        }
326
327        if !repo_config.ref_.is_empty() {
328            // A configured ref pins to a SHA/tag or tracks a branch (before
329            // the detached case).
330            return self.checkout_ref(&directory, &repo_config.ref_, false, result);
331        }
332
333        if state.is_detached {
334            // Detached HEAD - skip update.
335            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            // Branch has diverged (both ahead and behind).
343            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            // Branch is ahead - can't auto-update.
354            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            // Branch is behind - update it.
365            return self.pull_repository(&directory, result);
366        }
367
368        // Repository is clean and up-to-date.
369        result.status = UpdateStatus::Updated;
370        result.notes = "Already up-to-date".to_string();
371
372        result
373    }
374
375    /// Ported from `(*Updater).shouldRefreshTracking`. Reports whether to
376    /// fetch the upstream before the behind/ahead check.
377    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    /// Ported from `(*Updater).refreshTrackingBranch`. Fetches the current
393    /// branch's upstream and recomputes behind/ahead. Returns `Some` only
394    /// on fetch failure.
395    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    /// Ported from `(*Updater).pullRepository`. Pulls the latest changes.
422    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        // Upstream already refreshed (refresh_tracking_branch/checkout_ref),
434        // so this needs no network.
435        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    /// Ported from `(*Updater).cloneRepository`. Clones a missing
451    /// repository, checking out `ref_` if configured.
452    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            // Freshly cloned repo already has all refs; skip the redundant fetch.
480            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    /// Ported from `(*Updater).checkoutRef`. Checks out the ref: a
490    /// detached SHA/tag is pinned, a branch is fast-forwarded.
491    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                // Commit-ID fallback: fetch all branches when a direct
511                // commit fetch is refused.
512                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            // Detached HEAD (SHA or tag): pin, do not advance.
545            result.status = UpdateStatus::Pinned;
546            result.notes = format!("Pinned at {ref_}");
547
548            return result;
549        }
550
551        // A branch: fast-forward to the just-fetched tip.
552        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    /// Ported from `(*Updater).stashAndUpdate`. Stashes uncommitted
567    /// changes and then updates.
568    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
593/// Ported from the package-level `splitTrackingBranch` helper. Splits a
594/// remote branch name like `"origin/master"` into the remote and branch
595/// names (`"origin"`, `"master"`).
596fn 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
603/// Ported from `FormatTable` in `output.go`. Go's original renders a
604/// `pterm`-styled table with ANSI colors via `termutil`; no equivalent
605/// terminal-styling crate is in this workspace yet (see `PORT_NOTES.md`),
606/// so this port renders a plain-text table with the same columns and
607/// content, without color.
608pub 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(&notes, 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/// Ported from `FormatSummary` in `output.go`.
673#[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/// Ported from `FormatSuggestions` in `output.go`. Generates actionable
725/// suggestions for repositories needing attention.
726#[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
761/// Ported from `formatCount` in `output.go`.
762fn 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
775/// Ported from `getSuggestion` in `output.go`. Returns a helpful
776/// suggestion for a result that needs attention.
777fn 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
809/// Ported from `truncate` in `output.go`. Truncates a string to a maximum
810/// length, adding an ellipsis if needed.
811fn 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    /// Ported from `errTestCloneFailed`, `errTestExitStatus1`,
845    /// `errTestFetchFailed`, `errTestPullFailed`. Go's sentinel `errors.New`
846    /// values become `GitError::Other` so they carry through
847    /// `UpdateResult.error` the same way a wrapped Go error does.
848    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    /// A minimal [`Resolver`] test double, standing in for the real
865    /// `variables::resolver::new_resolver`'s `Resolver`. Ported tests only
866    /// need a [`Container`] that constructs without panicking; they do not
867    /// exercise variable expansion.
868    #[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    /// Ported from `newTestConfigBox`. Ensures `Cluster.Network` is non-nil so
896    /// the variables resolver can compute `CAPRONI_PRIMARY_HOSTNAME` when
897    /// lifecycle hooks enumerate variables.
898    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    /// Ported from `newRefTestConfig`.
914    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    /// Ported from `cleanExistingState`.
931    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    /// Ported from `noRefTrackingConfig`.
942    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    // --- Ported from TestDetermineRequiredRepositories ---
957
958    #[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    // --- Ported from TestUpdateRepositories_StrategyFullUpdate ---
1075
1076    #[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    // --- Ported from TestUpdateRepositories_ExistingRepoPinnedSHA ---
1132
1133    #[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    // --- Ported from TestUpdateRepositories_ExistingRepoRefBranch ---
1167
1168    #[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    // --- Ported from TestUpdateRepositories_CloneWithRef ---
1204
1205    #[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    // --- Ported from TestUpdateRepositories_RefCheckoutFailed ---
1246
1247    #[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    // --- Ported from TestUpdateRepositories_RefVerifyOnly ---
1279
1280    #[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    // --- Ported from TestUpdateRepositories_StrategyMinimalRequired ---
1310
1311    #[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    // --- Ported from TestUpdateRepositories_StrategyVerifyOnly ---
1361
1362    #[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    // --- Ported from TestUpdateRepositories_NonRequiredRepoNotCloned ---
1413
1414    #[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    // --- Ported from TestUpdateRepositories_ExistingRepoNeedsPull ---
1460
1461    #[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    // --- Ported from TestUpdateRepositories_NoRefRefreshDiscoversBehind ---
1513    //
1514    // Regression test for the silent no-update bug: DetectState is
1515    // network-free and reports 0 behind, but the scoped fetch of the current
1516    // branch reveals new upstream commits.
1517
1518    #[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    // --- Ported from TestUpdateRepositories_NoRefRefreshUpToDate ---
1558    //
1559    // Confirms the refresh fetch runs but no fast-forward happens when the
1560    // branch is genuinely current.
1561
1562    #[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    // --- Ported from TestUpdateRepositories_NoRefNoUpstreamSkipsFetch ---
1601    //
1602    // Confirms a branch with no upstream is left untouched (no fetch, no
1603    // branch switching).
1604
1605    #[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    // --- Ported from TestUpdateRepositories_NoRefFetchFailed ---
1641    //
1642    // Confirms a failed refresh fetch surfaces as a failure rather than a
1643    // silent up-to-date.
1644
1645    #[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    // --- Ported from TestUpdateRepositories_NoRefRefreshNonOriginRemote ---
1688    //
1689    // Confirms the refresh fetches from the remote the branch actually
1690    // tracks, not always origin.
1691
1692    #[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    // --- Ported from TestUpdateRepositories_RefShaFetchFallback ---
1731    //
1732    // Confirms that when a scoped fetch of a commit ID is refused, caproni
1733    // falls back to a full fetch before checkout.
1734
1735    #[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    // --- Ported from TestUpdateRepositories_DirtyRepository ---
1773
1774    #[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    // --- Ported from TestUpdateRepositories_StashAndUpdate ---
1822
1823    #[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    // --- Ported from TestUpdateRepositories_DivergedRepository ---
1876
1877    #[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    // --- Ported from TestUpdateRepositories_DetachedHead ---
1928
1929    #[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    // --- Ported from TestUpdateRepositories_FilterPredicate ---
1974
1975    #[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        // No expectation was registered for "/test/charts": if the code
2034        // under test called `detect_state` for the filtered-out repo, the
2035        // mock would panic on an unmatched call rather than silently
2036        // succeed, which is the same guarantee Go's
2037        // `mockBackend.AssertNotCalled(t, "DetectState", ctx, "/test/charts", ...)`
2038        // gives (this mock's `assert_not_called` only tracks method names,
2039        // not per-argument calls; see PORT_NOTES.md).
2040    }
2041
2042    // --- Ported from TestUpdateRepositories_FailureReturnsFailedStatus ---
2043
2044    #[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    // --- Ported from TestUpdateRepositories_MultipleReposPreservesOrder ---
2107
2108    #[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    // --- Ported from TestUpdateRepositories_RemoteURLMismatch ---
2157
2158    #[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    // --- Ported from TestUpdateRepositories_NotGitRepository ---
2203
2204    #[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); // StatusFailed should result in success = false
2240        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    // --- Ported from TestUpdateRepositories_Hooks ---
2247    //
2248    // Go's table test used one closure-typed struct field per optional mock
2249    // setup step; this port splits each case into its own test function
2250    // instead, since Rust closures capturing `&mut MockGitBackend`/
2251    // `&MockCommandRunner` with differing captures don't fit a single
2252    // homogeneous table row as cleanly as Go's `func(t, *mocks.Backend)`.
2253
2254    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    /// Ported from the per-case config construction shared by all
2278    /// `TestUpdateRepositories_Hooks` subtests.
2279    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    /// `xianyun_executor::exec::ExecError` stand-in for Go's
2564    /// `executor.ErrCommandFailed` sentinel used by the hooks execution
2565    /// tests.
2566    fn err_test_exit_status1_exec() -> xianyun_executor::exec::ExecError {
2567        xianyun_executor::exec::ExecError::CommandFailed(1)
2568    }
2569}