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