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