Skip to main content

qtcloud_devops_cli/git/
scan.rs

1use crate::git::types::*;
2use std::path::Path;
3
4/// 从 .gitmodules 解析子模块列表:name, path, url, branch
5pub fn parse_gitmodules(root: &Path) -> Vec<(String, PathBuf, String, String)> {
6    let cfg_path = root.join(".gitmodules");
7    let content = match std::fs::read_to_string(&cfg_path) {
8        Ok(c) => c,
9        Err(_) => return vec![],
10    };
11    let file = match gix::submodule::File::from_bytes(
12        content.as_bytes(),
13        None,
14        &gix::config::File::default(),
15    ) {
16        Ok(f) => f,
17        Err(_) => return vec![],
18    };
19    file.names()
20        .filter_map(|name| {
21            let name_str = name.to_string();
22            let p = file.path(name).ok()?;
23            let url = file.url(name).ok()?;
24            let branch = match file.branch(name).ok().flatten() {
25                Some(gix::submodule::config::Branch::Name(b)) => b.to_string(),
26                _ => "main".to_string(),
27            };
28            Some((
29                name_str,
30                PathBuf::from(p.as_ref().to_string()),
31                url.to_string(),
32                branch,
33            ))
34        })
35        .collect()
36}
37
38/// 用 gix 统计两个 commit 之间的提交数(等价于 `git rev-list --count from..to`)。
39fn gix_count_between(repo: &gix::Repository, from: gix::ObjectId, to: gix::ObjectId) -> usize {
40    // ponytail: 逐 parent 遍历而非 revwalk Platform,避免 API 兼容问题
41    let mut count = 0;
42    let mut current = to;
43    loop {
44        if current == from {
45            break;
46        }
47        if count > 10000 {
48            break;
49        }
50        let commit = match repo.find_commit(current) {
51            Ok(c) => c,
52            Err(_) => break,
53        };
54        let mut parents = commit.parent_ids();
55        match parents.next() {
56            Some(id) => current = id.into(),
57            None => break,
58        }
59        count += 1;
60    }
61    count
62}
63
64/// 用 gix 读取 HEAD:path tree entry 的 OID(支持多级路径)。
65fn gix_tree_entry_id(repo: &gix::Repository, path: &Path) -> Option<gix::ObjectId> {
66    let commit = repo.head_commit().ok()?;
67    let tree = commit.tree().ok()?;
68    let path_str = path.to_string_lossy();
69    let components: Vec<&str> = path_str.split('/').collect();
70    let mut buf = Vec::new();
71    let entry = tree.lookup_entry(components, &mut buf).ok()??;
72    Some(entry.id().into())
73}
74
75impl RepoState {
76    pub fn scan(root: &Path) -> Result<Self, Box<dyn std::error::Error>> {
77        Self::scan_with_options(root, false)
78    }
79
80    pub fn scan_offline(root: &Path) -> Result<Self, Box<dyn std::error::Error>> {
81        Self::scan_with_options(root, true)
82    }
83
84    fn scan_with_options(root: &Path, offline: bool) -> Result<Self, Box<dyn std::error::Error>> {
85        // 确认是 git 仓库
86        if gix::open(root).is_err() {
87            return Err(format!("不在 git 仓库中: {:?}", root).into());
88        }
89
90        let raw_entries = parse_gitmodules(root);
91        let mut submodules: Vec<Submodule> = Vec::with_capacity(raw_entries.len());
92
93        // gix 打开父仓库,用于 tree 查询 parent pointer
94        let parent_repo = gix::open(root).ok();
95
96        for (name, sm_path, url, branch) in &raw_entries {
97            let full_sm_path = root.join(sm_path);
98
99            let parent_pointer = parent_repo
100                .as_ref()
101                .and_then(|r| gix_tree_entry_id(r, sm_path))
102                .unwrap_or(gix::ObjectId::null(gix::hash::Kind::Sha1));
103
104            let (
105                local_head,
106                remote_head,
107                is_detached,
108                ahead_count,
109                behind_count,
110                is_orphaned,
111                remote_unreachable,
112                is_uninitialized,
113                is_dirty,
114            ) = Self::scan_single_submodule(&full_sm_path, branch, &parent_pointer, offline);
115
116            let status = Self::determine_submodule_status(
117                is_uninitialized,
118                is_dirty,
119                is_detached,
120                is_orphaned,
121                remote_unreachable,
122                ahead_count,
123                behind_count,
124                &local_head,
125                &parent_pointer,
126            );
127
128            submodules.push(Submodule {
129                name: name.clone(),
130                path: sm_path.clone(),
131                url: url.clone(),
132                tracked_branch: branch.clone(),
133                parent_pointer,
134                local_head,
135                remote_head,
136                status,
137                ahead_count,
138                behind_count,
139                remote_unreachable,
140            });
141        }
142
143        submodules.sort_by(|a, b| a.name.cmp(&b.name));
144        let total = submodules.len();
145        let clean_count = submodules
146            .iter()
147            .filter(|s| s.status == SubmoduleStatus::Clean)
148            .count();
149        let needs_attention: Vec<String> = submodules
150            .iter()
151            .filter(|s| s.status != SubmoduleStatus::Clean)
152            .map(|s| s.name.clone())
153            .collect();
154
155        Ok(RepoState {
156            root_path: root.to_path_buf(),
157            submodules,
158            total,
159            clean_count,
160            needs_attention,
161        })
162    }
163
164    /// 扫描单个子模块的远程状态,使用 gix + git2。
165    #[allow(clippy::too_many_arguments)]
166    fn scan_single_submodule(
167        full_sm_path: &Path,
168        branch: &str,
169        parent_pointer: &gix::ObjectId,
170        offline: bool,
171    ) -> (
172        gix::ObjectId,
173        gix::ObjectId,
174        bool,
175        usize,
176        usize,
177        bool,
178        bool,
179        bool,
180        bool,
181    ) {
182        // 子模块目录不存在 → 未初始化
183        if !full_sm_path.exists() {
184            return (
185                gix::ObjectId::null(gix::hash::Kind::Sha1),
186                gix::ObjectId::null(gix::hash::Kind::Sha1),
187                false,
188                0,
189                0,
190                false,
191                false,
192                true,
193                false,
194            );
195        }
196        // 不是 git 仓库
197        if !full_sm_path.join(".git").exists() {
198            return (
199                gix::ObjectId::null(gix::hash::Kind::Sha1),
200                gix::ObjectId::null(gix::hash::Kind::Sha1),
201                false,
202                0,
203                0,
204                false,
205                false,
206                true,
207                false,
208            );
209        }
210
211        // 用 gix 打开子模块仓库
212        let sm_repo = gix::open(full_sm_path).ok();
213
214        // 本地 HEAD
215        let local_head: gix::ObjectId = sm_repo
216            .as_ref()
217            .and_then(|r| r.head_commit().ok().map(|c| c.id().into()))
218            .unwrap_or(gix::ObjectId::null(gix::hash::Kind::Sha1));
219
220        // 是否游离 HEAD
221        let is_detached = sm_repo
222            .as_ref()
223            .map(|r| r.head().ok().map(|h| h.is_detached()).unwrap_or(false))
224            .unwrap_or(false);
225
226        // 是否 dirty — 用 git2
227        let is_dirty = git2::Repository::open(full_sm_path)
228            .map(|r| {
229                r.statuses(Some(git2::StatusOptions::new().include_untracked(true)))
230                    .map(|s| s.len() > 0)
231                    .unwrap_or(false)
232            })
233            .unwrap_or(false);
234
235        // fetch(非 offline 模式)— 用 git2
236        if !offline {
237            if let Ok(repo) = git2::Repository::open(full_sm_path) {
238                if let Ok(mut remote) = repo.find_remote("origin") {
239                    let _ = remote.fetch(&[] as &[&str], None, None);
240                }
241            }
242        }
243
244        // 远程 HEAD — 用 gix 查 refs/remotes/origin/{branch}
245        let remote_ref = format!("refs/remotes/origin/{}", branch);
246        let (remote_head, remote_unreachable) = sm_repo
247            .as_ref()
248            .and_then(|r| r.find_reference(&remote_ref).ok())
249            .map(|r| {
250                let target = r.target();
251                let oid = target.id();
252                let id: gix::ObjectId = (*oid).into();
253                if id.is_null() {
254                    (gix::ObjectId::null(gix::hash::Kind::Sha1), true)
255                } else {
256                    (id, false)
257                }
258            })
259            .unwrap_or((gix::ObjectId::null(gix::hash::Kind::Sha1), true));
260
261        // ahead / behind — 用 gix revwalk
262        let ahead = sm_repo
263            .as_ref()
264            .map(|r| gix_count_between(r, *parent_pointer, local_head))
265            .unwrap_or(0);
266
267        let behind = if remote_unreachable {
268            0
269        } else {
270            sm_repo
271                .as_ref()
272                .map(|r| gix_count_between(r, local_head, remote_head))
273                .unwrap_or(0)
274        };
275
276        // orphaned:父指针和远程不同
277        let is_orphaned = !remote_unreachable
278            && remote_head != gix::ObjectId::null(gix::hash::Kind::Sha1)
279            && parent_pointer != &remote_head
280            && *parent_pointer != remote_head;
281
282        (
283            local_head,
284            remote_head,
285            is_detached,
286            ahead,
287            behind,
288            is_orphaned,
289            remote_unreachable,
290            false,
291            is_dirty,
292        )
293    }
294
295    fn determine_submodule_status(
296        is_uninitialized: bool,
297        is_dirty: bool,
298        is_detached: bool,
299        is_orphaned: bool,
300        remote_unreachable: bool,
301        ahead_count: usize,
302        behind_count: usize,
303        local_head: &gix::ObjectId,
304        parent_pointer: &gix::ObjectId,
305    ) -> SubmoduleStatus {
306        if is_uninitialized {
307            return SubmoduleStatus::Uninitialized;
308        }
309        if is_dirty {
310            return SubmoduleStatus::Dirty;
311        }
312        if is_detached {
313            return SubmoduleStatus::Detached;
314        }
315        if is_orphaned && !remote_unreachable {
316            return SubmoduleStatus::Orphaned;
317        }
318        if (remote_unreachable && local_head != parent_pointer)
319            || (ahead_count > 0 && behind_count == 0)
320        {
321            return SubmoduleStatus::AheadOfParent;
322        }
323        if behind_count > 0 && !remote_unreachable {
324            return SubmoduleStatus::BehindRemote;
325        }
326        SubmoduleStatus::Clean
327    }
328
329    pub fn scan_all(
330        root: &Path,
331    ) -> Result<(Vec<Submodule>, AggregateStatus), Box<dyn std::error::Error>> {
332        let state = Self::scan(root)?;
333        let agg = AggregateStatus::from_submodules(&state.submodules);
334        Ok((state.submodules, agg))
335    }
336}
337#[cfg(test)]
338mod tests {
339    fn git_init(path: &std::path::Path) {
340        let repo = git2::Repository::init(path).unwrap();
341        let mut cfg = repo.config().unwrap();
342        cfg.set_str("user.email", "t@t").unwrap();
343        cfg.set_str("user.name", "t").unwrap();
344    }
345
346    fn git_commit(path: &std::path::Path, msg: &str) {
347        std::fs::write(path.join("f"), msg).unwrap();
348        let repo = git2::Repository::open(path).unwrap();
349        let mut index = repo.index().unwrap();
350        index.add_path(std::path::Path::new("f")).unwrap();
351        index.write().unwrap();
352        let tree_id = index.write_tree().unwrap();
353        let tree = repo.find_tree(tree_id).unwrap();
354        let sig = repo.signature().unwrap();
355        let parent = repo.head().and_then(|h| h.peel_to_commit()).ok();
356        let parents: Vec<&git2::Commit> = parent.iter().collect();
357        repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents).unwrap();
358    }
359
360    use super::*;
361    use std::process::Command;
362
363    fn setup_repo_with_submodule(tmp: &Path) -> PathBuf {
364        let parent = tmp.join("parent");
365        let sub = tmp.join("sub");
366        std::fs::create_dir_all(&sub).unwrap();
367        git_init(&sub);
368        git_commit(&sub, "init sub");
369        std::fs::create_dir_all(&parent).unwrap();
370        git_init(&parent);
371        git_commit(&parent, "init parent");
372        Command::new("git")
373            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
374            .current_dir(&parent)
375            .output()
376            .unwrap();
377        Command::new("git")
378            .args(["commit", "-m", "add submodule"])
379            .current_dir(&parent)
380            .output()
381            .unwrap();
382        parent
383    }
384
385    fn dh() -> gix::ObjectId {
386        gix::ObjectId::null(gix::hash::Kind::Sha1)
387    }
388    fn h(s: &str) -> gix::ObjectId {
389        // pad to 40 hex chars for gix::ObjectId::from_hex
390        let padded = format!("{:0>40}", s);
391        gix::ObjectId::from_hex(padded.as_bytes())
392            .unwrap_or(gix::ObjectId::null(gix::hash::Kind::Sha1))
393    }
394
395    // ---- determine_submodule_status ----
396    #[test]
397    fn test_determine_status_uninitialized() {
398        assert_eq!(
399            RepoState::determine_submodule_status(
400                true,
401                false,
402                false,
403                false,
404                false,
405                0,
406                0,
407                &dh(),
408                &dh()
409            ),
410            SubmoduleStatus::Uninitialized
411        );
412    }
413    #[test]
414    fn test_determine_status_dirty() {
415        assert_eq!(
416            RepoState::determine_submodule_status(
417                false,
418                true,
419                false,
420                false,
421                false,
422                0,
423                0,
424                &dh(),
425                &dh()
426            ),
427            SubmoduleStatus::Dirty
428        );
429    }
430    #[test]
431    fn test_determine_status_detached() {
432        assert_eq!(
433            RepoState::determine_submodule_status(
434                false,
435                false,
436                true,
437                false,
438                false,
439                0,
440                0,
441                &dh(),
442                &dh()
443            ),
444            SubmoduleStatus::Detached
445        );
446    }
447    #[test]
448    fn test_determine_status_orphaned() {
449        assert_eq!(
450            RepoState::determine_submodule_status(
451                false,
452                false,
453                false,
454                true,
455                false,
456                0,
457                0,
458                &dh(),
459                &dh()
460            ),
461            SubmoduleStatus::Orphaned
462        );
463    }
464    #[test]
465    fn test_determine_status_ahead_of_parent() {
466        assert_eq!(
467            RepoState::determine_submodule_status(
468                false,
469                false,
470                false,
471                false,
472                true,
473                0,
474                0,
475                &h("abc"),
476                &dh()
477            ),
478            SubmoduleStatus::AheadOfParent
479        );
480        assert_eq!(
481            RepoState::determine_submodule_status(
482                false,
483                false,
484                false,
485                false,
486                false,
487                5,
488                0,
489                &dh(),
490                &dh()
491            ),
492            SubmoduleStatus::AheadOfParent
493        );
494        assert_eq!(
495            RepoState::determine_submodule_status(
496                false,
497                false,
498                false,
499                false,
500                false,
501                5,
502                3,
503                &dh(),
504                &dh()
505            ),
506            SubmoduleStatus::BehindRemote
507        );
508    }
509    #[test]
510    fn test_determine_status_behind_remote() {
511        assert_eq!(
512            RepoState::determine_submodule_status(
513                false,
514                false,
515                false,
516                false,
517                false,
518                0,
519                3,
520                &dh(),
521                &dh()
522            ),
523            SubmoduleStatus::BehindRemote
524        );
525        assert_eq!(
526            RepoState::determine_submodule_status(
527                false,
528                false,
529                false,
530                false,
531                true,
532                0,
533                3,
534                &dh(),
535                &dh()
536            ),
537            SubmoduleStatus::Clean
538        );
539    }
540    #[test]
541    fn test_determine_status_clean() {
542        assert_eq!(
543            RepoState::determine_submodule_status(
544                false,
545                false,
546                false,
547                false,
548                false,
549                0,
550                0,
551                &dh(),
552                &dh()
553            ),
554            SubmoduleStatus::Clean
555        );
556    }
557
558    // ---- gix_count_between ----
559    #[test]
560    fn test_count_between_commits() {
561        let t = tempfile::tempdir().unwrap();
562        git_init(t.path());
563        git_commit(t.path(), "c1");
564        let repo = gix::open(t.path()).unwrap();
565        let head: gix::ObjectId = repo.head_commit().unwrap().id().into();
566        // HEAD..HEAD = 0
567        assert_eq!(gix_count_between(&repo, head, head), 0);
568        git_commit(t.path(), "c2");
569        let head2: gix::ObjectId = repo.head_commit().unwrap().id().into();
570        assert_eq!(gix_count_between(&repo, head, head2), 1);
571    }
572    #[test]
573    fn test_count_between_one_commit() {
574        let t = tempfile::tempdir().unwrap();
575        git_init(t.path());
576        git_commit(t.path(), "c1");
577        let repo = gix::open(t.path()).unwrap();
578        let c1: gix::ObjectId = repo.head_commit().unwrap().id().into();
579        git_commit(t.path(), "c2");
580        let head: gix::ObjectId = repo.head_commit().unwrap().id().into();
581        assert_eq!(gix_count_between(&repo, c1, head), 1);
582    }
583
584    // ---- scan tests ----
585    #[test]
586    fn test_scan_no_gitmodules() {
587        assert!(RepoState::scan(&tempfile::tempdir().unwrap().path()).is_err());
588    }
589    #[test]
590    fn test_scan_git_repo_but_no_submodules() {
591        let t = tempfile::tempdir().unwrap();
592        git_init(t.path());
593        git_commit(t.path(), "initial");
594        assert_eq!(RepoState::scan(t.path()).unwrap().total, 0);
595    }
596    #[test]
597    fn test_scan_non_git_directory() {
598        let t = tempfile::tempdir().unwrap();
599        std::fs::write(t.path().join(".gitmodules"), "").unwrap();
600        assert!(RepoState::scan(t.path()).is_err());
601    }
602    #[test]
603    fn test_scan_with_submodule() {
604        let t = tempfile::tempdir().unwrap();
605        let p = setup_repo_with_submodule(t.path());
606        let s = RepoState::scan(&p).unwrap();
607        assert_eq!(s.total, 1);
608        assert_eq!(s.submodules[0].name, "libs/sub");
609    }
610    #[test]
611    fn test_scan_all_no_gitmodules() {
612        assert!(RepoState::scan_all(&tempfile::tempdir().unwrap().path()).is_err());
613    }
614    #[test]
615    fn test_scan_all_with_submodule() {
616        let t = tempfile::tempdir().unwrap();
617        let p = setup_repo_with_submodule(t.path());
618        let (subs, _) = RepoState::scan_all(&p).unwrap();
619        assert_eq!(subs.len(), 1);
620    }
621
622    // ---- edge case scan tests ----
623    #[test]
624    fn test_scan_with_uninitialized_submodule() {
625        let tmp = tempfile::tempdir().unwrap();
626        let parent = tmp.path().join("parent");
627        std::fs::create_dir_all(&parent).unwrap();
628        git_init(&parent);
629        git_commit(&parent, "init");
630        let sub = tmp.path().join("sub");
631        std::fs::create_dir_all(&sub).unwrap();
632        git_init(&sub);
633        git_commit(&sub, "init");
634        Command::new("git")
635            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
636            .current_dir(&parent)
637            .output()
638            .unwrap();
639        Command::new("git")
640            .args(["commit", "-m", "add submodule"])
641            .current_dir(&parent)
642            .output()
643            .unwrap();
644        Command::new("git")
645            .args(["submodule", "deinit", "-f", "libs/sub"])
646            .current_dir(&parent)
647            .output()
648            .unwrap();
649        assert_eq!(
650            RepoState::scan(&parent).unwrap().submodules[0].status,
651            SubmoduleStatus::Uninitialized
652        );
653    }
654
655    #[test]
656    fn test_scan_with_detached_submodule() {
657        let tmp = tempfile::tempdir().unwrap();
658        let parent = setup_repo_with_submodule(tmp.path());
659        let sm_path = parent.join("libs/sub");
660        let hash = String::from_utf8_lossy(
661            &Command::new("git")
662                .args(["rev-parse", "HEAD"])
663                .current_dir(&sm_path)
664                .output()
665                .unwrap()
666                .stdout,
667        )
668        .trim()
669        .to_string();
670        Command::new("git")
671            .args(["checkout", "--detach", &hash])
672            .current_dir(&sm_path)
673            .output()
674            .unwrap();
675        assert_eq!(
676            RepoState::scan(&parent).unwrap().submodules[0].status,
677            SubmoduleStatus::Detached
678        );
679    }
680
681    #[test]
682    fn test_scan_with_ahead_via_remote_unreachable() {
683        let tmp = tempfile::tempdir().unwrap();
684        let parent = setup_repo_with_submodule(tmp.path());
685        let sm_path = parent.join("libs/sub");
686        std::fs::write(sm_path.join("new-file"), "content").unwrap();
687        Command::new("git")
688            .args(["add", "."])
689            .current_dir(&sm_path)
690            .output()
691            .unwrap();
692        Command::new("git")
693            .args(["commit", "-m", "ahead commit"])
694            .current_dir(&sm_path)
695            .output()
696            .unwrap();
697        Command::new("git")
698            .args(["remote", "remove", "origin"])
699            .current_dir(&sm_path)
700            .output()
701            .unwrap();
702        let state = RepoState::scan(&parent).unwrap();
703        assert_eq!(state.submodules[0].status, SubmoduleStatus::AheadOfParent);
704    }
705
706    #[test]
707    fn test_scan_with_subrepo_open_error() {
708        let tmp = tempfile::tempdir().unwrap();
709        let parent = setup_repo_with_submodule(tmp.path());
710        let sm_git = parent.join("libs/sub/.git");
711        if sm_git.is_dir() {
712            std::fs::remove_dir_all(&sm_git).unwrap();
713        } else {
714            std::fs::remove_file(&sm_git).unwrap();
715        }
716        assert_eq!(
717            RepoState::scan(&parent).unwrap().submodules[0].local_head,
718            gix::ObjectId::null(gix::hash::Kind::Sha1)
719        );
720    }
721
722    #[test]
723    fn test_scan_with_behind_remote() {
724        let tmp = tempfile::tempdir().unwrap();
725        let parent = tmp.path().join("parent");
726        let sub = tmp.path().join("sub");
727        let bare = tmp.path().join("bare");
728        std::fs::create_dir_all(&bare).unwrap();
729        Command::new("git")
730            .args(["init", "--bare", &bare.to_string_lossy()])
731            .current_dir(tmp.path())
732            .output()
733            .unwrap();
734        Command::new("git")
735            .args(["clone", &bare.to_string_lossy(), &sub.to_string_lossy()])
736            .current_dir(tmp.path())
737            .output()
738            .unwrap();
739        git_init(&sub);
740        git_commit(&sub, "init");
741        Command::new("git")
742            .args(["push", "origin", "main"])
743            .current_dir(&sub)
744            .output()
745            .unwrap();
746        std::fs::create_dir_all(&parent).unwrap();
747        git_init(&parent);
748        git_commit(&parent, "init parent");
749        Command::new("git")
750            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
751            .current_dir(&parent)
752            .output()
753            .unwrap();
754        Command::new("git")
755            .args(["commit", "-m", "add submodule"])
756            .current_dir(&parent)
757            .output()
758            .unwrap();
759        git_commit(&sub, "remote ahead");
760        Command::new("git")
761            .args(["push", "origin", "main"])
762            .current_dir(&sub)
763            .output()
764            .unwrap();
765        Command::new("git")
766            .args(["fetch", "origin"])
767            .current_dir(&parent.join("libs/sub"))
768            .output()
769            .unwrap();
770        assert_eq!(
771            RepoState::scan(&parent).unwrap().submodules[0].behind_count,
772            1
773        );
774    }
775
776    #[test]
777    fn test_scan_with_orphaned_submodule() {
778        let tmp = tempfile::tempdir().unwrap();
779        let parent = setup_repo_with_submodule(tmp.path());
780        let sm_path = parent.join("libs/sub");
781        Command::new("git")
782            .args(["remote", "remove", "origin"])
783            .current_dir(&sm_path)
784            .output()
785            .unwrap();
786        let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
787        std::fs::create_dir_all(&ref_dir).unwrap();
788        std::fs::write(
789            ref_dir.join("main"),
790            "1111111111111111111111111111111111111111\n",
791        )
792        .unwrap();
793        assert_eq!(
794            RepoState::scan(&parent).unwrap().submodules[0].status,
795            SubmoduleStatus::Orphaned
796        );
797    }
798
799    #[test]
800    fn test_scan_with_ahead_of_parent_clean() {
801        let tmp = tempfile::tempdir().unwrap();
802        let parent = setup_repo_with_submodule(tmp.path());
803        git_commit(&parent.join("libs/sub"), "ahead commit");
804        assert!(RepoState::scan(&parent).unwrap().submodules[0].ahead_count > 0);
805    }
806
807    #[test]
808    fn test_orphaned_parse_oid_failure() {
809        let tmp = tempfile::tempdir().unwrap();
810        let parent = setup_repo_with_submodule(tmp.path());
811        let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
812        if !ref_dir.exists() {
813            std::fs::create_dir_all(&ref_dir).unwrap();
814        }
815        std::fs::write(ref_dir.join("main"), "not-a-valid-oid\n").unwrap();
816        assert!(!RepoState::scan(&parent).unwrap().submodules.is_empty());
817    }
818
819    #[test]
820    fn test_ahead_of_parent_via_ahead_count() {
821        let tmp = tempfile::tempdir().unwrap();
822        let parent = setup_repo_with_submodule(tmp.path());
823        let sm_path = parent.join("libs/sub");
824        Command::new("git")
825            .args(["remote", "remove", "origin"])
826            .current_dir(&sm_path)
827            .output()
828            .unwrap();
829        std::fs::write(sm_path.join("new-file"), "content").unwrap();
830        Command::new("git")
831            .args(["add", "."])
832            .current_dir(&sm_path)
833            .output()
834            .unwrap();
835        Command::new("git")
836            .args(["commit", "-m", "ahead"])
837            .current_dir(&sm_path)
838            .output()
839            .unwrap();
840        let state = RepoState::scan(&parent).unwrap();
841        assert_eq!(state.submodules[0].ahead_count, 1);
842    }
843}