git_traversal.rs

  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}