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