1use git::status::GitSummary;
2use std::{ops::Deref, path::Path};
3use sum_tree::Cursor;
4use text::Bias;
5use worktree::{Entry, PathProgress, PathTarget, RepositoryEntry, StatusEntry, Traversal};
6
7/// Walks the worktree entries and their associated git statuses.
8pub struct GitTraversal<'a> {
9 traversal: Traversal<'a>,
10 current_entry_summary: Option<GitSummary>,
11 repo_location: Option<(
12 &'a RepositoryEntry,
13 Cursor<'a, StatusEntry, PathProgress<'a>>,
14 )>,
15}
16
17impl<'a> GitTraversal<'a> {
18 pub fn new(traversal: Traversal<'a>) -> GitTraversal<'a> {
19 let mut this = GitTraversal {
20 traversal,
21 current_entry_summary: None,
22 repo_location: None,
23 };
24 this.synchronize_statuses(true);
25 this
26 }
27
28 fn synchronize_statuses(&mut self, reset: bool) {
29 self.current_entry_summary = None;
30
31 let Some(entry) = self.entry() else {
32 return;
33 };
34
35 let Some(repo) = self.traversal.snapshot().repository_for_path(&entry.path) else {
36 self.repo_location = None;
37 return;
38 };
39
40 // Update our state if we changed repositories.
41 if reset
42 || self
43 .repo_location
44 .as_ref()
45 .map(|(prev_repo, _)| &prev_repo.work_directory)
46 != Some(&repo.work_directory)
47 {
48 self.repo_location = Some((repo, repo.statuses_by_path.cursor::<PathProgress>(&())));
49 }
50
51 let Some((repo, statuses)) = &mut self.repo_location else {
52 return;
53 };
54
55 let repo_path = repo.relativize(&entry.path).unwrap();
56
57 if entry.is_dir() {
58 let mut statuses = statuses.clone();
59 statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &());
60 let summary =
61 statuses.summary(&PathTarget::Successor(repo_path.as_ref()), Bias::Left, &());
62
63 self.current_entry_summary = Some(summary);
64 } else if entry.is_file() {
65 // For a file entry, park the cursor on the corresponding status
66 if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) {
67 // TODO: Investigate statuses.item() being None here.
68 self.current_entry_summary = statuses.item().map(|item| item.status.into());
69 } else {
70 self.current_entry_summary = Some(GitSummary::UNCHANGED);
71 }
72 }
73 }
74
75 pub fn advance(&mut self) -> bool {
76 self.advance_by(1)
77 }
78
79 pub fn advance_by(&mut self, count: usize) -> bool {
80 let found = self.traversal.advance_by(count);
81 self.synchronize_statuses(false);
82 found
83 }
84
85 pub fn advance_to_sibling(&mut self) -> bool {
86 let found = self.traversal.advance_to_sibling();
87 self.synchronize_statuses(false);
88 found
89 }
90
91 pub fn back_to_parent(&mut self) -> bool {
92 let found = self.traversal.back_to_parent();
93 self.synchronize_statuses(true);
94 found
95 }
96
97 pub fn start_offset(&self) -> usize {
98 self.traversal.start_offset()
99 }
100
101 pub fn end_offset(&self) -> usize {
102 self.traversal.end_offset()
103 }
104
105 pub fn entry(&self) -> Option<GitEntryRef<'a>> {
106 let entry = self.traversal.entry()?;
107 let git_summary = self.current_entry_summary.unwrap_or(GitSummary::UNCHANGED);
108 Some(GitEntryRef { entry, git_summary })
109 }
110}
111
112impl<'a> Iterator for GitTraversal<'a> {
113 type Item = GitEntryRef<'a>;
114
115 fn next(&mut self) -> Option<Self::Item> {
116 if let Some(item) = self.entry() {
117 self.advance();
118 Some(item)
119 } else {
120 None
121 }
122 }
123}
124
125pub struct ChildEntriesGitIter<'a> {
126 parent_path: &'a Path,
127 traversal: GitTraversal<'a>,
128}
129
130impl<'a> ChildEntriesGitIter<'a> {
131 pub fn new(snapshot: &'a worktree::Snapshot, parent_path: &'a Path) -> Self {
132 let mut traversal =
133 GitTraversal::new(snapshot.traverse_from_path(true, true, true, parent_path));
134 traversal.advance();
135 ChildEntriesGitIter {
136 parent_path,
137 traversal,
138 }
139 }
140}
141
142impl<'a> Iterator for ChildEntriesGitIter<'a> {
143 type Item = GitEntryRef<'a>;
144
145 fn next(&mut self) -> Option<Self::Item> {
146 if let Some(item) = self.traversal.entry() {
147 if item.path.starts_with(self.parent_path) {
148 self.traversal.advance_to_sibling();
149 return Some(item);
150 }
151 }
152 None
153 }
154}
155
156#[derive(Debug, Clone, Copy)]
157pub struct GitEntryRef<'a> {
158 pub entry: &'a Entry,
159 pub git_summary: GitSummary,
160}
161
162impl GitEntryRef<'_> {
163 pub fn to_owned(&self) -> GitEntry {
164 GitEntry {
165 entry: self.entry.clone(),
166 git_summary: self.git_summary,
167 }
168 }
169}
170
171impl Deref for GitEntryRef<'_> {
172 type Target = Entry;
173
174 fn deref(&self) -> &Self::Target {
175 &self.entry
176 }
177}
178
179impl AsRef<Entry> for GitEntryRef<'_> {
180 fn as_ref(&self) -> &Entry {
181 self.entry
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct GitEntry {
187 pub entry: Entry,
188 pub git_summary: GitSummary,
189}
190
191impl GitEntry {
192 pub fn to_ref(&self) -> GitEntryRef {
193 GitEntryRef {
194 entry: &self.entry,
195 git_summary: self.git_summary,
196 }
197 }
198}
199
200impl Deref for GitEntry {
201 type Target = Entry;
202
203 fn deref(&self) -> &Self::Target {
204 &self.entry
205 }
206}
207
208impl AsRef<Entry> for GitEntry {
209 fn as_ref(&self) -> &Entry {
210 &self.entry
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use std::time::Duration;
217
218 use super::*;
219 use fs::FakeFs;
220 use git::status::{FileStatus, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode};
221 use gpui::TestAppContext;
222 use serde_json::json;
223 use settings::{Settings as _, SettingsStore};
224 use util::path;
225 use worktree::{Worktree, WorktreeSettings};
226
227 const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
228 first_head: UnmergedStatusCode::Updated,
229 second_head: UnmergedStatusCode::Updated,
230 });
231 const ADDED: GitSummary = GitSummary {
232 index: TrackedSummary::ADDED,
233 count: 1,
234 ..GitSummary::UNCHANGED
235 };
236 const MODIFIED: GitSummary = GitSummary {
237 index: TrackedSummary::MODIFIED,
238 count: 1,
239 ..GitSummary::UNCHANGED
240 };
241
242 #[gpui::test]
243 async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
244 init_test(cx);
245 let fs = FakeFs::new(cx.background_executor.clone());
246 fs.insert_tree(
247 path!("/root"),
248 json!({
249 "x": {
250 ".git": {},
251 "x1.txt": "foo",
252 "x2.txt": "bar",
253 "y": {
254 ".git": {},
255 "y1.txt": "baz",
256 "y2.txt": "qux"
257 },
258 "z.txt": "sneaky..."
259 },
260 "z": {
261 ".git": {},
262 "z1.txt": "quux",
263 "z2.txt": "quuux"
264 }
265 }),
266 )
267 .await;
268
269 fs.set_status_for_repo(
270 Path::new(path!("/root/x/.git")),
271 &[
272 (Path::new("x2.txt"), StatusCode::Modified.index()),
273 (Path::new("z.txt"), StatusCode::Added.index()),
274 ],
275 );
276 fs.set_status_for_repo(
277 Path::new(path!("/root/x/y/.git")),
278 &[(Path::new("y1.txt"), CONFLICT)],
279 );
280 fs.set_status_for_repo(
281 Path::new(path!("/root/z/.git")),
282 &[(Path::new("z2.txt"), StatusCode::Added.index())],
283 );
284
285 let tree = Worktree::local(
286 Path::new(path!("/root")),
287 true,
288 fs.clone(),
289 Default::default(),
290 &mut cx.to_async(),
291 )
292 .await
293 .unwrap();
294
295 cx.executor().run_until_parked();
296
297 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
298
299 let mut traversal =
300 GitTraversal::new(snapshot.traverse_from_path(true, false, true, Path::new("x")));
301
302 let entry = traversal.next().unwrap();
303 assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt"));
304 assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
305 let entry = traversal.next().unwrap();
306 assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt"));
307 assert_eq!(entry.git_summary, MODIFIED);
308 let entry = traversal.next().unwrap();
309 assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt"));
310 assert_eq!(entry.git_summary, GitSummary::CONFLICT);
311 let entry = traversal.next().unwrap();
312 assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt"));
313 assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
314 let entry = traversal.next().unwrap();
315 assert_eq!(entry.path.as_ref(), Path::new("x/z.txt"));
316 assert_eq!(entry.git_summary, ADDED);
317 let entry = traversal.next().unwrap();
318 assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt"));
319 assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
320 let entry = traversal.next().unwrap();
321 assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt"));
322 assert_eq!(entry.git_summary, ADDED);
323 }
324
325 #[gpui::test]
326 async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
327 init_test(cx);
328 let fs = FakeFs::new(cx.background_executor.clone());
329 fs.insert_tree(
330 path!("/root"),
331 json!({
332 "x": {
333 ".git": {},
334 "x1.txt": "foo",
335 "x2.txt": "bar",
336 "y": {
337 ".git": {},
338 "y1.txt": "baz",
339 "y2.txt": "qux"
340 },
341 "z.txt": "sneaky..."
342 },
343 "z": {
344 ".git": {},
345 "z1.txt": "quux",
346 "z2.txt": "quuux"
347 }
348 }),
349 )
350 .await;
351
352 fs.set_status_for_repo(
353 Path::new(path!("/root/x/.git")),
354 &[
355 (Path::new("x2.txt"), StatusCode::Modified.index()),
356 (Path::new("z.txt"), StatusCode::Added.index()),
357 ],
358 );
359 fs.set_status_for_repo(
360 Path::new(path!("/root/x/y/.git")),
361 &[(Path::new("y1.txt"), CONFLICT)],
362 );
363
364 fs.set_status_for_repo(
365 Path::new(path!("/root/z/.git")),
366 &[(Path::new("z2.txt"), StatusCode::Added.index())],
367 );
368
369 let tree = Worktree::local(
370 Path::new(path!("/root")),
371 true,
372 fs.clone(),
373 Default::default(),
374 &mut cx.to_async(),
375 )
376 .await
377 .unwrap();
378
379 cx.executor().run_until_parked();
380
381 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
382
383 // Sanity check the propagation for x/y and z
384 check_git_statuses(
385 &snapshot,
386 &[
387 (Path::new("x/y"), GitSummary::CONFLICT),
388 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
389 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
390 ],
391 );
392 check_git_statuses(
393 &snapshot,
394 &[
395 (Path::new("z"), ADDED),
396 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
397 (Path::new("z/z2.txt"), ADDED),
398 ],
399 );
400
401 // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
402 check_git_statuses(
403 &snapshot,
404 &[
405 (Path::new("x"), MODIFIED + ADDED),
406 (Path::new("x/y"), GitSummary::CONFLICT),
407 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
408 ],
409 );
410
411 // Sanity check everything around it
412 check_git_statuses(
413 &snapshot,
414 &[
415 (Path::new("x"), MODIFIED + ADDED),
416 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
417 (Path::new("x/x2.txt"), MODIFIED),
418 (Path::new("x/y"), GitSummary::CONFLICT),
419 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
420 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
421 (Path::new("x/z.txt"), ADDED),
422 ],
423 );
424
425 // Test the other fundamental case, transitioning from git repository to non-git repository
426 check_git_statuses(
427 &snapshot,
428 &[
429 (Path::new(""), GitSummary::UNCHANGED),
430 (Path::new("x"), MODIFIED + ADDED),
431 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
432 ],
433 );
434
435 // And all together now
436 check_git_statuses(
437 &snapshot,
438 &[
439 (Path::new(""), GitSummary::UNCHANGED),
440 (Path::new("x"), MODIFIED + ADDED),
441 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
442 (Path::new("x/x2.txt"), MODIFIED),
443 (Path::new("x/y"), GitSummary::CONFLICT),
444 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
445 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
446 (Path::new("x/z.txt"), ADDED),
447 (Path::new("z"), ADDED),
448 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
449 (Path::new("z/z2.txt"), ADDED),
450 ],
451 );
452 }
453
454 #[gpui::test]
455 async fn test_git_traversal_simple(cx: &mut TestAppContext) {
456 init_test(cx);
457 let fs = FakeFs::new(cx.background_executor.clone());
458 fs.insert_tree(
459 path!("/root"),
460 json!({
461 ".git": {},
462 "a": {
463 "b": {
464 "c1.txt": "",
465 "c2.txt": "",
466 },
467 "d": {
468 "e1.txt": "",
469 "e2.txt": "",
470 "e3.txt": "",
471 }
472 },
473 "f": {
474 "no-status.txt": ""
475 },
476 "g": {
477 "h1.txt": "",
478 "h2.txt": ""
479 },
480 }),
481 )
482 .await;
483
484 fs.set_status_for_repo(
485 Path::new(path!("/root/.git")),
486 &[
487 (Path::new("a/b/c1.txt"), StatusCode::Added.index()),
488 (Path::new("a/d/e2.txt"), StatusCode::Modified.index()),
489 (Path::new("g/h2.txt"), CONFLICT),
490 ],
491 );
492
493 let tree = Worktree::local(
494 Path::new(path!("/root")),
495 true,
496 fs.clone(),
497 Default::default(),
498 &mut cx.to_async(),
499 )
500 .await
501 .unwrap();
502 cx.executor().run_until_parked();
503
504 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
505
506 check_git_statuses(
507 &snapshot,
508 &[
509 (Path::new(""), GitSummary::CONFLICT + MODIFIED + ADDED),
510 (Path::new("g"), GitSummary::CONFLICT),
511 (Path::new("g/h2.txt"), GitSummary::CONFLICT),
512 ],
513 );
514
515 check_git_statuses(
516 &snapshot,
517 &[
518 (Path::new(""), GitSummary::CONFLICT + ADDED + MODIFIED),
519 (Path::new("a"), ADDED + MODIFIED),
520 (Path::new("a/b"), ADDED),
521 (Path::new("a/b/c1.txt"), ADDED),
522 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
523 (Path::new("a/d"), MODIFIED),
524 (Path::new("a/d/e2.txt"), MODIFIED),
525 (Path::new("f"), GitSummary::UNCHANGED),
526 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
527 (Path::new("g"), GitSummary::CONFLICT),
528 (Path::new("g/h2.txt"), GitSummary::CONFLICT),
529 ],
530 );
531
532 check_git_statuses(
533 &snapshot,
534 &[
535 (Path::new("a/b"), ADDED),
536 (Path::new("a/b/c1.txt"), ADDED),
537 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
538 (Path::new("a/d"), MODIFIED),
539 (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
540 (Path::new("a/d/e2.txt"), MODIFIED),
541 (Path::new("f"), GitSummary::UNCHANGED),
542 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
543 (Path::new("g"), GitSummary::CONFLICT),
544 ],
545 );
546
547 check_git_statuses(
548 &snapshot,
549 &[
550 (Path::new("a/b/c1.txt"), ADDED),
551 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
552 (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
553 (Path::new("a/d/e2.txt"), MODIFIED),
554 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
555 ],
556 );
557 }
558
559 #[gpui::test]
560 async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
561 init_test(cx);
562 let fs = FakeFs::new(cx.background_executor.clone());
563 fs.insert_tree(
564 path!("/root"),
565 json!({
566 "x": {
567 ".git": {},
568 "x1.txt": "foo",
569 "x2.txt": "bar"
570 },
571 "y": {
572 ".git": {},
573 "y1.txt": "baz",
574 "y2.txt": "qux"
575 },
576 "z": {
577 ".git": {},
578 "z1.txt": "quux",
579 "z2.txt": "quuux"
580 }
581 }),
582 )
583 .await;
584
585 fs.set_status_for_repo(
586 Path::new(path!("/root/x/.git")),
587 &[(Path::new("x1.txt"), StatusCode::Added.index())],
588 );
589 fs.set_status_for_repo(
590 Path::new(path!("/root/y/.git")),
591 &[
592 (Path::new("y1.txt"), CONFLICT),
593 (Path::new("y2.txt"), StatusCode::Modified.index()),
594 ],
595 );
596 fs.set_status_for_repo(
597 Path::new(path!("/root/z/.git")),
598 &[(Path::new("z2.txt"), StatusCode::Modified.index())],
599 );
600
601 let tree = Worktree::local(
602 Path::new(path!("/root")),
603 true,
604 fs.clone(),
605 Default::default(),
606 &mut cx.to_async(),
607 )
608 .await
609 .unwrap();
610 cx.executor().run_until_parked();
611
612 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
613
614 check_git_statuses(
615 &snapshot,
616 &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
617 );
618
619 check_git_statuses(
620 &snapshot,
621 &[
622 (Path::new("y"), GitSummary::CONFLICT + MODIFIED),
623 (Path::new("y/y1.txt"), GitSummary::CONFLICT),
624 (Path::new("y/y2.txt"), MODIFIED),
625 ],
626 );
627
628 check_git_statuses(
629 &snapshot,
630 &[
631 (Path::new("z"), MODIFIED),
632 (Path::new("z/z2.txt"), MODIFIED),
633 ],
634 );
635
636 check_git_statuses(
637 &snapshot,
638 &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
639 );
640
641 check_git_statuses(
642 &snapshot,
643 &[
644 (Path::new("x"), ADDED),
645 (Path::new("x/x1.txt"), ADDED),
646 (Path::new("x/x2.txt"), GitSummary::UNCHANGED),
647 (Path::new("y"), GitSummary::CONFLICT + MODIFIED),
648 (Path::new("y/y1.txt"), GitSummary::CONFLICT),
649 (Path::new("y/y2.txt"), MODIFIED),
650 (Path::new("z"), MODIFIED),
651 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
652 (Path::new("z/z2.txt"), MODIFIED),
653 ],
654 );
655 }
656
657 fn init_test(cx: &mut gpui::TestAppContext) {
658 if std::env::var("RUST_LOG").is_ok() {
659 env_logger::try_init().ok();
660 }
661
662 cx.update(|cx| {
663 let settings_store = SettingsStore::test(cx);
664 cx.set_global(settings_store);
665 WorktreeSettings::register(cx);
666 });
667 }
668
669 #[gpui::test]
670 async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
671 init_test(cx);
672
673 // Create a worktree with a git directory.
674 let fs = FakeFs::new(cx.background_executor.clone());
675 fs.insert_tree(
676 path!("/root"),
677 json!({
678 ".git": {},
679 "a.txt": "",
680 "b": {
681 "c.txt": "",
682 },
683 }),
684 )
685 .await;
686 fs.set_head_and_index_for_repo(
687 path!("/root/.git").as_ref(),
688 &[("a.txt".into(), "".into()), ("b/c.txt".into(), "".into())],
689 );
690 cx.run_until_parked();
691
692 let tree = Worktree::local(
693 path!("/root").as_ref(),
694 true,
695 fs.clone(),
696 Default::default(),
697 &mut cx.to_async(),
698 )
699 .await
700 .unwrap();
701 cx.executor().run_until_parked();
702
703 let (old_entry_ids, old_mtimes) = tree.read_with(cx, |tree, _| {
704 (
705 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
706 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
707 )
708 });
709
710 // Regression test: after the directory is scanned, touch the git repo's
711 // working directory, bumping its mtime. That directory keeps its project
712 // entry id after the directories are re-scanned.
713 fs.touch_path(path!("/root")).await;
714 cx.executor().run_until_parked();
715
716 let (new_entry_ids, new_mtimes) = tree.read_with(cx, |tree, _| {
717 (
718 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
719 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
720 )
721 });
722 assert_eq!(new_entry_ids, old_entry_ids);
723 assert_ne!(new_mtimes, old_mtimes);
724
725 // Regression test: changes to the git repository should still be
726 // detected.
727 fs.set_head_for_repo(
728 path!("/root/.git").as_ref(),
729 &[
730 ("a.txt".into(), "".into()),
731 ("b/c.txt".into(), "something-else".into()),
732 ],
733 );
734 cx.executor().run_until_parked();
735 cx.executor().advance_clock(Duration::from_secs(1));
736
737 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
738
739 check_git_statuses(
740 &snapshot,
741 &[
742 (Path::new(""), MODIFIED),
743 (Path::new("a.txt"), GitSummary::UNCHANGED),
744 (Path::new("b/c.txt"), MODIFIED),
745 ],
746 );
747 }
748
749 #[track_caller]
750 fn check_git_statuses(
751 snapshot: &worktree::Snapshot,
752 expected_statuses: &[(&Path, GitSummary)],
753 ) {
754 let mut traversal =
755 GitTraversal::new(snapshot.traverse_from_path(true, true, false, "".as_ref()));
756 let found_statuses = expected_statuses
757 .iter()
758 .map(|&(path, _)| {
759 let git_entry = traversal
760 .find(|git_entry| &*git_entry.path == path)
761 .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
762 (path, git_entry.git_summary)
763 })
764 .collect::<Vec<_>>();
765 assert_eq!(found_statuses, expected_statuses);
766 }
767}