Skip to main content

qtcloud_devops_cli/
model.rs

1use std::path::PathBuf;
2
3pub mod release;
4
5#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
6pub struct CommitHash(pub String);
7
8impl std::fmt::Display for CommitHash {
9    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10        write!(f, "{}", &self.0[..self.0.len().min(7)])
11    }
12}
13
14impl Default for CommitHash {
15    fn default() -> Self {
16        Self(String::from("0000000000000000000000000000000000000000"))
17    }
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
21pub enum SubmoduleStatus {
22    Clean,
23    AheadOfParent,
24    BehindRemote,
25    Detached,
26    Dirty,
27    Orphaned,
28    Uninitialized,
29}
30
31impl SubmoduleStatus {
32    pub fn priority(&self) -> u8 {
33        match self {
34            Self::Dirty => 0,
35            Self::Orphaned => 1,
36            Self::Detached => 2,
37            Self::Uninitialized => 3,
38            Self::BehindRemote => 4,
39            Self::AheadOfParent => 5,
40            Self::Clean => 6,
41        }
42    }
43}
44
45#[derive(Debug, Clone, serde::Serialize)]
46pub struct Submodule {
47    pub name: String,
48    pub path: PathBuf,
49    pub url: String,
50    pub tracked_branch: String,
51    pub parent_pointer: CommitHash,
52    pub local_head: CommitHash,
53    pub remote_head: CommitHash,
54    pub status: SubmoduleStatus,
55    pub ahead_count: usize,
56    pub behind_count: usize,
57    pub remote_unreachable: bool,
58}
59
60#[derive(Debug, Clone, serde::Serialize)]
61pub struct RepoState {
62    pub root_path: PathBuf,
63    pub submodules: Vec<Submodule>,
64    pub total: usize,
65    pub clean_count: usize,
66    pub needs_attention: Vec<String>,
67    pub parent_dirty: bool,
68}
69
70impl RepoState {
71    pub fn scan(root: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
72        let repo = match git2::Repository::open(root) {
73            Ok(r) => r,
74            Err(e) => return Err(format!("无法打开 Git 仓库 '{}': {}", root.display(), e).into()),
75        };
76        let gitmodules_path = root.join(".gitmodules");
77        let mut submodules = Vec::new();
78
79        if gitmodules_path.exists() {
80            let mut git_submodules = repo.submodules()?;
81            git_submodules.sort_by(|a, b| a.name().cmp(&b.name()));
82
83            for sm in &git_submodules {
84            let name = sm.name().unwrap_or("unknown").to_string();
85            let sm_path = sm.path();
86            let full_sm_path = root.join(sm_path);
87            let url = sm.url().unwrap_or("").to_string();
88            let branch = sm.branch().unwrap_or("main").to_string();
89
90            let raw_status = repo.submodule_status(&name, git2::SubmoduleIgnore::None)?;
91            let is_uninitialized = raw_status.is_wd_uninitialized();
92            let is_dirty = raw_status.is_wd_modified()
93                || raw_status.is_index_modified()
94                || raw_status.is_wd_untracked();
95
96            // 父仓库记录的 commit
97            let head_oid = sm.head_id().unwrap_or_else(git2::Oid::zero);
98            let parent_pointer = CommitHash(head_oid.to_string());
99
100            let (
101                local_head,
102                remote_head,
103                is_detached,
104                ahead_count,
105                behind_count,
106                is_orphaned,
107                remote_unreachable,
108            ) = if is_uninitialized {
109                (
110                    CommitHash::default(),
111                    CommitHash::default(),
112                    false,
113                    0,
114                    0,
115                    false,
116                    false,
117                )
118            } else {
119                match git2::Repository::open(&full_sm_path) {
120                    Ok(sub_repo) => {
121                        let local = sub_repo
122                            .head()
123                            .ok()
124                            .and_then(|r| r.target())
125                            .map(|o| CommitHash(o.to_string()))
126                            .unwrap_or_default();
127
128                        let detached = sub_repo
129                            .head()
130                            .ok()
131                            .map(|r| !r.is_branch())
132                            .unwrap_or(false);
133
134                        let (remote, unreachable) = sub_repo
135                            .find_reference(&format!("refs/remotes/origin/{}", branch))
136                            .ok()
137                            .and_then(|r| r.target())
138                            .map(|o| (CommitHash(o.to_string()), false))
139                            .unwrap_or_else(|| (CommitHash::default(), true));
140
141                        let ahead = count_between_opt(
142                            &sub_repo,
143                            parse_oid(&parent_pointer),
144                            parse_oid(&local),
145                        );
146                        let behind = if unreachable {
147                            0
148                        } else {
149                            count_between_opt(&sub_repo, parse_oid(&local), parse_oid(&remote))
150                        };
151
152                        let orphaned = if !unreachable
153                            && remote != CommitHash::default()
154                            && parent_pointer != remote
155                        {
156                            let p = parse_oid(&parent_pointer);
157                            let r = parse_oid(&remote);
158                            match (p, r) {
159                                (Some(p_oid), Some(r_oid)) => sub_repo
160                                    .merge_base(r_oid, p_oid)
161                                    .map(|base| base != p_oid)
162                                    .unwrap_or(true),
163                                _ => false,
164                            }
165                        } else {
166                            false
167                        };
168
169                        (
170                            local,
171                            remote,
172                            detached,
173                            ahead,
174                            behind,
175                            orphaned,
176                            unreachable,
177                        )
178                    }
179                    Err(_) => (
180                        CommitHash::default(),
181                        CommitHash::default(),
182                        false,
183                        0,
184                        0,
185                        false,
186                        false,
187                    ),
188                }
189            };
190
191            let status = if is_uninitialized {
192                SubmoduleStatus::Uninitialized
193            } else if is_dirty {
194                SubmoduleStatus::Dirty
195            } else if is_detached {
196                SubmoduleStatus::Detached
197            } else if is_orphaned && !remote_unreachable {
198                SubmoduleStatus::Orphaned
199            } else if (remote_unreachable && local_head != parent_pointer)
200                || (ahead_count > 0 && behind_count == 0)
201            {
202                SubmoduleStatus::AheadOfParent
203            } else if behind_count > 0 && !remote_unreachable {
204                SubmoduleStatus::BehindRemote
205            } else {
206                SubmoduleStatus::Clean
207            };
208
209            submodules.push(Submodule {
210                name,
211                path: sm_path.to_path_buf(),
212                url,
213                tracked_branch: branch,
214                parent_pointer,
215                local_head,
216                remote_head,
217                status,
218                ahead_count,
219                behind_count,
220                remote_unreachable,
221            });
222        }
223        } // end if gitmodules_path.exists()
224
225        let total = submodules.len();
226        let clean_count = submodules
227            .iter()
228            .filter(|s| s.status == SubmoduleStatus::Clean)
229            .count();
230        let needs_attention: Vec<String> = submodules
231            .iter()
232            .filter(|s| s.status != SubmoduleStatus::Clean)
233            .map(|s| s.name.clone())
234            .collect();
235
236        let parent_dirty = repo
237            .statuses(Some(
238                git2::StatusOptions::new()
239                    .include_untracked(true)
240                    .recurse_untracked_dirs(true),
241            ))
242            .map(|statuses| {
243                statuses
244                    .iter()
245                    .filter(|e| e.path().map_or(true, |p| !std::path::Path::new(p).starts_with(".gitmodules")))
246                    .any(|e| e.status() != git2::Status::CURRENT)
247            })
248            .unwrap_or(false);
249
250        Ok(RepoState {
251            root_path: root.to_path_buf(),
252            submodules,
253            total,
254            clean_count,
255            needs_attention,
256            parent_dirty,
257        })
258    }
259
260    pub fn scan_all(
261        root: &std::path::Path,
262    ) -> Result<(Vec<Submodule>, AggregateStatus), Box<dyn std::error::Error>> {
263        let state = Self::scan(root)?;
264        let agg = AggregateStatus::from_submodules(&state.submodules);
265        Ok((state.submodules, agg))
266    }
267}
268
269#[derive(Debug, Clone, Default, serde::Serialize)]
270pub struct AggregateStatus {
271    pub total: usize,
272    pub clean: usize,
273    pub ahead_of_parent: usize,
274    pub behind_remote: usize,
275    pub detached: usize,
276    pub dirty: usize,
277    pub orphaned: usize,
278    pub uninitialized: usize,
279}
280
281impl AggregateStatus {
282    pub fn from_submodules(submodules: &[Submodule]) -> Self {
283        let mut clean = 0;
284        let mut ahead = 0;
285        let mut behind = 0;
286        let mut detached = 0;
287        let mut dirty = 0;
288        let mut orphaned = 0;
289        let mut uninit = 0;
290        for sm in submodules {
291            match sm.status {
292                SubmoduleStatus::Clean => clean += 1,
293                SubmoduleStatus::AheadOfParent => ahead += 1,
294                SubmoduleStatus::BehindRemote => behind += 1,
295                SubmoduleStatus::Detached => detached += 1,
296                SubmoduleStatus::Dirty => dirty += 1,
297                SubmoduleStatus::Orphaned => orphaned += 1,
298                SubmoduleStatus::Uninitialized => uninit += 1,
299            }
300        }
301        AggregateStatus {
302            total: submodules.len(),
303            clean,
304            ahead_of_parent: ahead,
305            behind_remote: behind,
306            detached,
307            dirty,
308            orphaned,
309            uninitialized: uninit,
310        }
311    }
312}
313
314fn parse_oid(h: &CommitHash) -> Option<git2::Oid> {
315    git2::Oid::from_str(&h.0).ok()
316}
317
318fn count_between_opt(
319    repo: &git2::Repository,
320    from: Option<git2::Oid>,
321    to: Option<git2::Oid>,
322) -> usize {
323    let (Some(from), Some(to)) = (from, to) else {
324        return 0;
325    };
326    if from == to {
327        return 0;
328    }
329    let mut walk = match repo.revwalk() {
330        Ok(w) => w,
331        Err(_) => return 0,
332    };
333    if walk.push(to).is_err() || walk.hide(from).is_err() {
334        return 0;
335    }
336    walk.count()
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::process::Command;
343
344    fn git_init(repo_path: &std::path::Path) {
345        Command::new("git")
346            .args(["init"])
347            .current_dir(repo_path)
348            .output()
349            .unwrap();
350        Command::new("git")
351            .args(["config", "user.email", "test@test.com"])
352            .current_dir(repo_path)
353            .output()
354            .unwrap();
355        Command::new("git")
356            .args(["config", "user.name", "Test"])
357            .current_dir(repo_path)
358            .output()
359            .unwrap();
360    }
361
362    fn git_commit(repo_path: &std::path::Path, msg: &str) {
363        std::fs::write(repo_path.join("file"), msg).unwrap();
364        Command::new("git")
365            .args(["add", "."])
366            .current_dir(repo_path)
367            .output()
368            .unwrap();
369        Command::new("git")
370            .args(["commit", "-m", msg])
371            .current_dir(repo_path)
372            .output()
373            .unwrap();
374    }
375
376    /// Create a parent repo with a submodule at `libs/sub`.
377    fn setup_repo_with_submodule(tmp: &std::path::Path) -> std::path::PathBuf {
378        let parent = tmp.join("parent");
379        let sub = tmp.join("sub");
380
381        std::fs::create_dir_all(&sub).unwrap();
382        git_init(&sub);
383        git_commit(&sub, "init sub");
384
385        std::fs::create_dir_all(&parent).unwrap();
386        git_init(&parent);
387        std::fs::write(parent.join("README.md"), "# parent").unwrap();
388        Command::new("git")
389            .args(["add", "."])
390            .current_dir(&parent)
391            .output()
392            .unwrap();
393        Command::new("git")
394            .args(["commit", "-m", "init parent"])
395            .current_dir(&parent)
396            .output()
397            .unwrap();
398        Command::new("git")
399            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
400            .current_dir(&parent)
401            .output()
402            .unwrap();
403        Command::new("git")
404            .args(["commit", "-m", "add submodule"])
405            .current_dir(&parent)
406            .output()
407            .unwrap();
408        parent
409    }
410
411    // ---- SubmoduleStatus ----
412
413    #[test]
414    fn test_status_priority_ordering() {
415        assert!(SubmoduleStatus::Dirty.priority() < SubmoduleStatus::Clean.priority());
416        assert!(SubmoduleStatus::Orphaned.priority() < SubmoduleStatus::BehindRemote.priority());
417        assert!(SubmoduleStatus::Detached.priority() < SubmoduleStatus::AheadOfParent.priority());
418        assert!(SubmoduleStatus::Uninitialized.priority() < SubmoduleStatus::Clean.priority());
419    }
420
421    #[test]
422    fn test_clean_is_lowest_priority() {
423        let statuses = [
424            SubmoduleStatus::Dirty,
425            SubmoduleStatus::Orphaned,
426            SubmoduleStatus::Detached,
427            SubmoduleStatus::Uninitialized,
428            SubmoduleStatus::BehindRemote,
429            SubmoduleStatus::AheadOfParent,
430        ];
431        for s in &statuses {
432            assert!(s.priority() < SubmoduleStatus::Clean.priority());
433        }
434    }
435
436    #[test]
437    fn test_all_priorities_are_unique() {
438        let priorities: Vec<u8> = [
439            SubmoduleStatus::Dirty,
440            SubmoduleStatus::Orphaned,
441            SubmoduleStatus::Detached,
442            SubmoduleStatus::Uninitialized,
443            SubmoduleStatus::BehindRemote,
444            SubmoduleStatus::AheadOfParent,
445            SubmoduleStatus::Clean,
446        ]
447        .iter()
448        .map(|s| s.priority())
449        .collect();
450        let mut sorted = priorities.clone();
451        sorted.sort();
452        sorted.dedup();
453        assert_eq!(priorities.len(), sorted.len());
454    }
455
456    #[test]
457    fn test_status_debug_output() {
458        assert_eq!(format!("{:?}", SubmoduleStatus::Clean), "Clean");
459        assert_eq!(format!("{:?}", SubmoduleStatus::Dirty), "Dirty");
460        assert_eq!(format!("{:?}", SubmoduleStatus::Orphaned), "Orphaned");
461        assert_eq!(format!("{:?}", SubmoduleStatus::Detached), "Detached");
462        assert_eq!(
463            format!("{:?}", SubmoduleStatus::Uninitialized),
464            "Uninitialized"
465        );
466        assert_eq!(
467            format!("{:?}", SubmoduleStatus::AheadOfParent),
468            "AheadOfParent"
469        );
470        assert_eq!(
471            format!("{:?}", SubmoduleStatus::BehindRemote),
472            "BehindRemote"
473        );
474    }
475
476    #[test]
477    fn test_status_clone_eq() {
478        let a = SubmoduleStatus::Dirty;
479        let b = a.clone();
480        assert_eq!(a, b);
481    }
482
483    // ---- CommitHash ----
484
485    #[test]
486    fn test_commit_hash_display_truncates() {
487        let hash = CommitHash("abcdef1234567890".to_string());
488        assert_eq!(hash.to_string(), "abcdef1");
489    }
490
491    #[test]
492    fn test_commit_hash_display_short() {
493        let hash = CommitHash("abc".to_string());
494        assert_eq!(hash.to_string(), "abc");
495    }
496
497    #[test]
498    fn test_commit_hash_display_empty() {
499        let hash = CommitHash(String::new());
500        assert_eq!(hash.to_string(), "");
501    }
502
503    #[test]
504    fn test_commit_hash_equality() {
505        let a = CommitHash("abc".to_string());
506        let b = CommitHash("abc".to_string());
507        let c = CommitHash("def".to_string());
508        assert_eq!(a, b);
509        assert_ne!(a, c);
510    }
511
512    #[test]
513    fn test_commit_hash_default() {
514        let d = CommitHash::default();
515        assert_eq!(d.0, "0000000000000000000000000000000000000000");
516        assert_eq!(d.to_string(), "0000000");
517    }
518
519    #[test]
520    fn test_commit_hash_clone() {
521        let a = CommitHash("deadbeef".to_string());
522        let b = a.clone();
523        assert_eq!(a, b);
524    }
525
526    // ---- Submodule ----
527
528    #[test]
529    fn test_submodule_builder() {
530        let sm = Submodule {
531            name: "test".into(),
532            path: PathBuf::from("libs/test"),
533            url: "https://example.com/test.git".into(),
534            tracked_branch: "main".into(),
535            parent_pointer: CommitHash("aaa".into()),
536            local_head: CommitHash("bbb".into()),
537            remote_head: CommitHash("ccc".into()),
538            status: SubmoduleStatus::BehindRemote,
539            ahead_count: 0,
540            behind_count: 3,
541            remote_unreachable: false,
542        };
543        assert_eq!(sm.name, "test");
544        assert_eq!(sm.behind_count, 3);
545        assert!(!sm.remote_unreachable);
546    }
547
548    // ---- AggregateStatus ----
549
550    #[test]
551    fn test_aggregate_status_default() {
552        let agg = AggregateStatus::default();
553        assert_eq!(agg.total, 0);
554    }
555
556    #[test]
557    fn test_aggregate_status_from_submodules() {
558        let sms = vec![
559            Submodule {
560                name: "a".into(),
561                path: PathBuf::new(),
562                url: String::new(),
563                tracked_branch: "main".into(),
564                parent_pointer: CommitHash::default(),
565                local_head: CommitHash::default(),
566                remote_head: CommitHash::default(),
567                status: SubmoduleStatus::Clean,
568                ahead_count: 0,
569                behind_count: 0,
570                remote_unreachable: false,
571            },
572            Submodule {
573                name: "b".into(),
574                path: PathBuf::new(),
575                url: String::new(),
576                tracked_branch: "main".into(),
577                parent_pointer: CommitHash::default(),
578                local_head: CommitHash::default(),
579                remote_head: CommitHash::default(),
580                status: SubmoduleStatus::Dirty,
581                ahead_count: 0,
582                behind_count: 0,
583                remote_unreachable: false,
584            },
585            Submodule {
586                name: "c".into(),
587                path: PathBuf::new(),
588                url: String::new(),
589                tracked_branch: "main".into(),
590                parent_pointer: CommitHash::default(),
591                local_head: CommitHash::default(),
592                remote_head: CommitHash::default(),
593                status: SubmoduleStatus::Orphaned,
594                ahead_count: 0,
595                behind_count: 0,
596                remote_unreachable: false,
597            },
598        ];
599        let agg = AggregateStatus::from_submodules(&sms);
600        assert_eq!(agg.total, 3);
601        assert_eq!(agg.clean, 1);
602        assert_eq!(agg.dirty, 1);
603        assert_eq!(agg.orphaned, 1);
604    }
605
606    #[test]
607    fn test_aggregate_status_all_variants() {
608        let make_sm = |status: SubmoduleStatus| Submodule {
609            name: String::new(),
610            path: PathBuf::new(),
611            url: String::new(),
612            tracked_branch: "main".into(),
613            parent_pointer: CommitHash::default(),
614            local_head: CommitHash::default(),
615            remote_head: CommitHash::default(),
616            status,
617            ahead_count: 0,
618            behind_count: 0,
619            remote_unreachable: false,
620        };
621        let sms = vec![
622            make_sm(SubmoduleStatus::Clean),
623            make_sm(SubmoduleStatus::AheadOfParent),
624            make_sm(SubmoduleStatus::BehindRemote),
625            make_sm(SubmoduleStatus::Detached),
626            make_sm(SubmoduleStatus::Dirty),
627            make_sm(SubmoduleStatus::Orphaned),
628            make_sm(SubmoduleStatus::Uninitialized),
629        ];
630        let agg = AggregateStatus::from_submodules(&sms);
631        assert_eq!(agg.total, 7);
632        assert_eq!(agg.clean, 1);
633        assert_eq!(agg.ahead_of_parent, 1);
634        assert_eq!(agg.behind_remote, 1);
635        assert_eq!(agg.detached, 1);
636        assert_eq!(agg.dirty, 1);
637        assert_eq!(agg.orphaned, 1);
638        assert_eq!(agg.uninitialized, 1);
639    }
640
641    // ---- parse_oid ----
642
643    #[test]
644    fn test_parse_oid_valid() {
645        let oid = parse_oid(&CommitHash(
646            "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0".into(),
647        ));
648        assert!(oid.is_some());
649    }
650
651    #[test]
652    fn test_parse_oid_invalid() {
653        let oid = parse_oid(&CommitHash("not-a-hex-string".into()));
654        assert!(oid.is_none());
655    }
656
657    #[test]
658    fn test_parse_oid_empty() {
659        let oid = parse_oid(&CommitHash(String::new()));
660        assert!(oid.is_none());
661    }
662
663    // ---- count_between_opt ----
664
665    #[test]
666    fn test_count_between_opt_both_none() {
667        let tmp = tempfile::tempdir().unwrap();
668        git_init(tmp.path());
669        let repo = git2::Repository::open(tmp.path()).unwrap();
670        assert_eq!(count_between_opt(&repo, None, None), 0);
671    }
672
673    #[test]
674    fn test_count_between_opt_some_and_none() {
675        let tmp = tempfile::tempdir().unwrap();
676        git_init(tmp.path());
677        let repo = git2::Repository::open(tmp.path()).unwrap();
678        let oid = git2::Oid::from_str(
679            "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
680        )
681        .ok();
682        assert_eq!(count_between_opt(&repo, oid, None), 0);
683        assert_eq!(count_between_opt(&repo, None, oid), 0);
684    }
685
686    #[test]
687    fn test_count_between_opt_equal_oids() {
688        let tmp = tempfile::tempdir().unwrap();
689        git_init(tmp.path());
690        git_commit(tmp.path(), "c1");
691        let repo = git2::Repository::open(tmp.path()).unwrap();
692        let head = repo.head().unwrap().target().unwrap();
693        assert_eq!(count_between_opt(&repo, Some(head), Some(head)), 0);
694    }
695
696    #[test]
697    fn test_count_between_opt_from_to() {
698        let tmp = tempfile::tempdir().unwrap();
699        git_init(tmp.path());
700        git_commit(tmp.path(), "c1");
701        let repo = git2::Repository::open(tmp.path()).unwrap();
702        let c1 = repo.head().unwrap().target().unwrap();
703        git_commit(tmp.path(), "c2");
704        let c2 = repo.head().unwrap().target().unwrap();
705        // from=c1, to=c2 => 1 commit between them
706        assert_eq!(count_between_opt(&repo, Some(c1), Some(c2)), 1);
707    }
708
709    // ---- RepoState::scan ----
710
711    #[test]
712    fn test_scan_no_gitmodules() {
713        let tmp = tempfile::tempdir().unwrap();
714        let result = RepoState::scan(tmp.path());
715        assert!(result.is_err());
716    }
717
718    #[test]
719    fn test_scan_git_repo_but_no_submodules() {
720        let tmp = tempfile::tempdir().unwrap();
721        git_init(tmp.path());
722        git_commit(tmp.path(), "initial");
723        let state = RepoState::scan(tmp.path()).unwrap();
724        assert_eq!(state.total, 0);
725        assert!(state.submodules.is_empty());
726    }
727
728    #[test]
729    fn test_scan_non_git_directory() {
730        let tmp = tempfile::tempdir().unwrap();
731        std::fs::write(tmp.path().join(".gitmodules"), "").unwrap();
732        // .gitmodules exists but not a git repo → error
733        let result = RepoState::scan(tmp.path());
734        assert!(result.is_err());
735    }
736
737    #[test]
738    fn test_scan_with_submodule() {
739        let tmp = tempfile::tempdir().unwrap();
740        let parent = setup_repo_with_submodule(tmp.path());
741        let state = RepoState::scan(&parent).unwrap();
742        assert_eq!(state.total, 1);
743        assert_eq!(state.submodules[0].name, "libs/sub");
744        assert_eq!(state.submodules[0].path, std::path::Path::new("libs/sub"));
745    }
746
747    // ---- RepoState::scan_all ----
748
749    #[test]
750    fn test_scan_all_no_gitmodules() {
751        let tmp = tempfile::tempdir().unwrap();
752        let result = RepoState::scan_all(tmp.path());
753        assert!(result.is_err());
754    }
755
756    #[test]
757    fn test_scan_all_with_submodule() {
758        let tmp = tempfile::tempdir().unwrap();
759        let parent = setup_repo_with_submodule(tmp.path());
760        let (subs, agg) = RepoState::scan_all(&parent).unwrap();
761        assert_eq!(subs.len(), 1);
762        assert_eq!(agg.total, 1);
763    }
764
765    // ---- RepoState ----
766
767    #[test]
768    fn test_repo_state_empty() {
769        let state = RepoState {
770            root_path: PathBuf::from("/tmp"),
771            submodules: vec![],
772            total: 0,
773            clean_count: 0,
774            needs_attention: vec![],
775            parent_dirty: false,
776        };
777        assert_eq!(state.total, 0);
778    }
779
780    // ---- helpers for edge case tests ----
781
782    fn git_bare_init(path: &std::path::Path) {
783        Command::new("git")
784            .args(["init", "--bare"])
785            .current_dir(path.parent().unwrap())
786            .arg(path)
787            .output()
788            .unwrap();
789    }
790
791    fn git_add_remote(repo: &std::path::Path, name: &str, url: &std::path::Path) {
792        Command::new("git")
793            .args(["remote", "add", name, &url.to_string_lossy()])
794            .current_dir(repo)
795            .output()
796            .unwrap();
797    }
798
799    fn git_fetch(repo: &std::path::Path) {
800        Command::new("git")
801            .args(["fetch", "origin"])
802            .current_dir(repo)
803            .output()
804            .unwrap();
805    }
806
807    // ---- edge case tests ----
808
809    #[test]
810    fn test_scan_with_uninitialized_submodule() {
811        let tmp = tempfile::tempdir().unwrap();
812        let parent = tmp.path().join("parent");
813        std::fs::create_dir_all(&parent).unwrap();
814        git_init(&parent);
815        git_commit(&parent, "init");
816        // Create sub repo
817        let sub = tmp.path().join("sub");
818        std::fs::create_dir_all(&sub).unwrap();
819        git_init(&sub);
820        git_commit(&sub, "init");
821        // Add submodule (this fully initializes it)
822        Command::new("git")
823            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
824            .current_dir(&parent)
825            .output()
826            .unwrap();
827        Command::new("git")
828            .args(["commit", "-m", "add submodule"])
829            .current_dir(&parent)
830            .output()
831            .unwrap();
832        // Deinitialize the submodule
833        Command::new("git")
834            .args(["submodule", "deinit", "-f", "libs/sub"])
835            .current_dir(&parent)
836            .output()
837            .unwrap();
838        let state = RepoState::scan(&parent).unwrap();
839        assert_eq!(state.submodules[0].status, SubmoduleStatus::Uninitialized);
840    }
841
842    #[test]
843    fn test_scan_with_detached_submodule() {
844        let tmp = tempfile::tempdir().unwrap();
845        let parent = setup_repo_with_submodule(tmp.path());
846        let sm_path = parent.join("libs/sub");
847        // Detach HEAD in submodule
848        let head = Command::new("git")
849            .args(["rev-parse", "HEAD"])
850            .current_dir(&sm_path)
851            .output()
852            .unwrap();
853        let hash = String::from_utf8_lossy(&head.stdout).trim().to_string();
854        Command::new("git")
855            .args(["checkout", "--detach", &hash])
856            .current_dir(&sm_path)
857            .output()
858            .unwrap();
859        let state = RepoState::scan(&parent).unwrap();
860        assert_eq!(state.submodules[0].status, SubmoduleStatus::Detached);
861    }
862
863    #[test]
864    fn test_scan_with_ahead_via_remote_unreachable() {
865        let tmp = tempfile::tempdir().unwrap();
866        let parent = setup_repo_with_submodule(tmp.path());
867        let sm_path = parent.join("libs/sub");
868        // Commit in submodule (makes ahead_count > 0)
869        std::fs::write(sm_path.join("new-file"), "content").unwrap();
870        Command::new("git")
871            .args(["add", "."])
872            .current_dir(&sm_path)
873            .output()
874            .unwrap();
875        Command::new("git")
876            .args(["commit", "-m", "ahead commit"])
877            .current_dir(&sm_path)
878            .output()
879            .unwrap();
880        // Remove origin remote to make remote_unreachable
881        Command::new("git")
882            .args(["remote", "remove", "origin"])
883            .current_dir(&sm_path)
884            .output()
885            .unwrap();
886        let state = RepoState::scan(&parent).unwrap();
887        // With remote unreachable and ahead_count > 0 → AheadOfParent
888        // But Dirty takes priority because git2 sees WD_INDEX_MODIFIED
889        // actually WD_MODIFIED includes HEAD mismatch, so status is Dirty
890        assert_eq!(state.submodules[0].status, SubmoduleStatus::Dirty);
891        assert_eq!(state.submodules[0].ahead_count, 1);
892        assert!(state.submodules[0].remote_unreachable);
893    }
894
895    #[test]
896    fn test_scan_with_subrepo_open_error() {
897        let tmp = tempfile::tempdir().unwrap();
898        let parent = setup_repo_with_submodule(tmp.path());
899        // Remove the submodule's .git to force open error
900        let sm_git = parent.join("libs/sub/.git");
901        if sm_git.exists() {
902            if sm_git.is_dir() {
903                std::fs::remove_dir_all(&sm_git).unwrap();
904            } else {
905                std::fs::remove_file(&sm_git).unwrap();
906            }
907        }
908        // Submodule entry exists in .gitmodules but .git is gone
909        let state = RepoState::scan(&parent).unwrap();
910        // Should not crash; submodule returns default values
911        assert_eq!(state.submodules[0].local_head, CommitHash::default());
912        assert!(!state.submodules[0].remote_unreachable);
913    }
914
915    #[test]
916    fn test_scan_with_behind_remote() {
917        let tmp = tempfile::tempdir().unwrap();
918        let parent = tmp.path().join("parent");
919        let sub = tmp.path().join("sub");
920        let bare = tmp.path().join("bare");
921        // Create bare repo as remote
922        std::fs::create_dir_all(&bare).unwrap();
923        Command::new("git")
924            .args(["init", "--bare", &bare.to_string_lossy()])
925            .current_dir(tmp.path())
926            .output()
927            .unwrap();
928        // Clone sub from bare
929        Command::new("git")
930            .args(["clone", &bare.to_string_lossy(), &sub.to_string_lossy()])
931            .current_dir(tmp.path())
932            .output()
933            .unwrap();
934        git_init(&sub);
935        git_commit(&sub, "init");
936        // Push to bare
937        Command::new("git")
938            .args(["push", "origin", "main"])
939            .current_dir(&sub)
940            .output()
941            .unwrap();
942        // Create parent and add submodule
943        std::fs::create_dir_all(&parent).unwrap();
944        git_init(&parent);
945        git_commit(&parent, "init parent");
946        Command::new("git")
947            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
948            .current_dir(&parent)
949            .output()
950            .unwrap();
951        Command::new("git")
952            .args(["commit", "-m", "add submodule"])
953            .current_dir(&parent)
954            .output()
955            .unwrap();
956        // Advance sub remote with a new commit
957        git_commit(&sub, "remote ahead");
958        Command::new("git")
959            .args(["push", "origin", "main"])
960            .current_dir(&sub)
961            .output()
962            .unwrap();
963        // Fetch in submodule so origin/main is updated
964        Command::new("git")
965            .args(["fetch", "origin"])
966            .current_dir(&parent.join("libs/sub"))
967            .output()
968            .unwrap();
969        let state = RepoState::scan(&parent).unwrap();
970        assert_eq!(state.submodules[0].behind_count, 1);
971    }
972
973    #[test]
974    fn test_count_between_opt_revwalk_fail() {
975        let tmp = tempfile::tempdir().unwrap();
976        git_init(tmp.path());
977        let repo = git2::Repository::open(tmp.path()).unwrap();
978        let oid = git2::Oid::from_str(
979            "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
980        )
981        .ok();
982        // Walk on empty repo with nonexistent OID → should fail gracefully
983        assert_eq!(count_between_opt(&repo, oid, oid), 0);
984    }
985
986    #[test]
987    fn test_scan_with_orphaned_submodule() {
988        let tmp = tempfile::tempdir().unwrap();
989        let parent = setup_repo_with_submodule(tmp.path());
990        // Write the remote tracking ref directly to bypass packed-refs issues
991        let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
992        std::fs::create_dir_all(&ref_dir).unwrap();
993        std::fs::write(ref_dir.join("main"), "1111111111111111111111111111111111111111\n").unwrap();
994        // Also remove packed-refs entry if it exists
995        let packed = parent.join(".git/modules/libs/sub/packed-refs");
996        if packed.exists() {
997            let content = std::fs::read_to_string(&packed).unwrap();
998            let new_content: Vec<&str> = content
999                .lines()
1000                .filter(|l| !l.contains("refs/remotes/origin/main"))
1001                .collect();
1002            std::fs::write(&packed, new_content.join("\n") + "\n").unwrap();
1003        }
1004        let state = RepoState::scan(&parent).unwrap();
1005        assert_eq!(state.submodules[0].status, SubmoduleStatus::Orphaned);
1006    }
1007
1008    #[test]
1009    fn test_scan_with_ahead_of_parent_clean() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        let parent = setup_repo_with_submodule(tmp.path());
1012        let sm_path = parent.join("libs/sub");
1013        // Commit in submodule (ahead_count becomes 1)
1014        git_commit(&sm_path, "ahead commit");
1015        // The submodule has ahead=1 but also git2 sees it as Dirty (WD_MODIFIED)
1016        // To get pure AheadOfParent, we need to make the submodule NOT dirty
1017        // This is tricky because git2 reports WD_MODIFIED when HEAD differs
1018        // The easiest path: remove remote so remote_unreachable + ahead > 0
1019        let state = RepoState::scan(&parent).unwrap();
1020        assert!(state.submodules[0].ahead_count > 0);
1021    }
1022
1023    #[test]
1024    fn test_count_between_opt_push_hide_fail() {
1025        let tmp = tempfile::tempdir().unwrap();
1026        git_init(tmp.path());
1027        git_commit(tmp.path(), "c1");
1028        let repo = git2::Repository::open(tmp.path()).unwrap();
1029        let head = repo.head().unwrap().target().unwrap();
1030        let bad_oid = git2::Oid::from_str(
1031            "0000000000000000000000000000000000000000",
1032        )
1033        .ok();
1034        // push with bad OID that doesn't exist in walk
1035        assert_eq!(count_between_opt(&repo, Some(head), bad_oid), 0);
1036    }
1037
1038    #[test]
1039    fn test_orphaned_parse_oid_failure() {
1040        let tmp = tempfile::tempdir().unwrap();
1041        let parent = setup_repo_with_submodule(tmp.path());
1042        // Write a ref with an invalid OID (not hex) to trigger parse_oid failure
1043        let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
1044        if !ref_dir.exists() {
1045            std::fs::create_dir_all(&ref_dir).unwrap();
1046        }
1047        std::fs::write(ref_dir.join("main"), "not-a-valid-oid\n").unwrap();
1048        let packed = parent.join(".git/modules/libs/sub/packed-refs");
1049        if packed.exists() {
1050            let content = std::fs::read_to_string(&packed).unwrap();
1051            let new_content: Vec<&str> = content
1052                .lines()
1053                .filter(|l| !l.contains("refs/remotes/origin/main"))
1054                .collect();
1055            std::fs::write(&packed, new_content.join("\n") + "\n").unwrap();
1056        }
1057        let state = RepoState::scan(&parent).unwrap();
1058        // Invalid OID → parse fails → is_orphaned stays false (not orphaned)
1059        // But remote_unreachable is also false (ref found with unparsable OID)
1060        // So this tests the _ => false arm
1061        assert!(!state.submodules.is_empty());
1062    }
1063
1064    #[test]
1065    fn test_ahead_of_parent_via_ahead_count() {
1066        let tmp = tempfile::tempdir().unwrap();
1067        let parent = setup_repo_with_submodule(tmp.path());
1068        let sm_path = parent.join("libs/sub");
1069        // Remove remote so remote_unreachable=true
1070        Command::new("git")
1071            .args(["remote", "remove", "origin"])
1072            .current_dir(&sm_path)
1073            .output()
1074            .unwrap();
1075        // Commit in submodule to get ahead_count > 0
1076        std::fs::write(sm_path.join("new-file"), "content").unwrap();
1077        Command::new("git")
1078            .args(["add", "."])
1079            .current_dir(&sm_path)
1080            .output()
1081            .unwrap();
1082        Command::new("git")
1083            .args(["commit", "-m", "ahead"])
1084            .current_dir(&sm_path)
1085            .output()
1086            .unwrap();
1087        let state = RepoState::scan(&parent).unwrap();
1088        assert_eq!(state.submodules[0].ahead_count, 1);
1089        assert!(state.submodules[0].remote_unreachable);
1090    }
1091}