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