Skip to main content

qtcloud_devops_cli/model/
code.rs

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