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}