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