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