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 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 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 } 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 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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(count_between_opt(&repo, Some(c1), Some(c2)), 1);
718 }
719
720 #[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 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 #[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 #[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 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 #[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 let sub = tmp.path().join("sub");
829 std::fs::create_dir_all(&sub).unwrap();
830 git_init(&sub);
831 git_commit(&sub, "init");
832 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 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 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 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 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 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 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 let state = RepoState::scan(&parent).unwrap();
919 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 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 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 Command::new("git")
947 .args(["push", "origin", "main"])
948 .current_dir(&sub)
949 .output()
950 .unwrap();
951 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 git_commit(&sub, "remote ahead");
967 Command::new("git")
968 .args(["push", "origin", "main"])
969 .current_dir(&sub)
970 .output()
971 .unwrap();
972 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 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 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 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 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 git_commit(&sm_path, "ahead commit");
1029 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 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 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 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 Command::new("git")
1085 .args(["remote", "remove", "origin"])
1086 .current_dir(&sm_path)
1087 .output()
1088 .unwrap();
1089 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}