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